一种适用于 Zustand 和 React Query 的前端数据管理方式
在上面这篇文章中提到当 React Query 托管的数据过于复杂或者冗余的话,后续在前端做数据的乐观更新会变得非常复杂并且不可控。
Note乐观更新指,前端在进行数据操作时,通过 API 方式向服务器提交数据,之后数据在 UI 上的更新由前端提供,不需要等待服务器的响应。这样的方式对用户来说几乎无感。
业务描述
现在我们有这样一个场景,数据结构类似这样的。
接口是个分页的,结合 React Query 的 InfiniteQuery 我们会去这样使用它。
export const entries = {
entries: ({
level,
id,
view,
read,
}: {
level?: string
id?: number | string
view?: number
read?: boolean
}) =>
defineQuery(
["entries", level, id, view, read],
async ({ pageParam }) =>
// 这里为接口
entryActions.fetchEntries({
level,
id,
view,
read,
pageParam: pageParam as string,
}),
),
}
useInfiniteQuery(entries.entries({ level, id, view, read }), {
enabled: level !== undefined && id !== undefined,
getNextPageParam: (lastPage) => {
if (!lastPage.data?.length) {
return null
}
return lastPage.data.at(-1)!.entries.publishedAt
},
initialPageParam: undefined,
})
我们可以通过 React Query 去使用数据了。
现在我们需要更新这个数据的 read 字段,这个时候我们需要乐观更新,因为 markRead 操作是一个很频繁的操作,不能一次操作之后重新拉后端数据去覆盖当前数据,这样不仅 UI 上的反馈很慢,而且造成浏览浪费。
下面是我们通过 React Query 自带的 Cache Store 去更新数据,但是这样不仅效率很低而且因为没有足够的类型推导,很容易出错,后期基本无法继续维护。再者,在 React Query 中,使用 useQuery 和 useInfiniteQuery 两种方式对最后的数据结构是不同的,但是我们的数据可能是相同的。
Zustand + React Query
现在我们放弃使用 React Query 托管数据,转而使用 Zustand 的方式。
和后端的 Table 一样,我们也在前端建立数据的映射表。如上图的数据集,我们可以利用 Zustand 建立一个 entryId -> entry 的表。
export const useEntryStore = create<EntryState & EntryActions>((set, get) => ({
entries: {}, // entryId -> entry 的表
fetchEntries: async ({
level,
id,
view,
read,
pageParam,
}: {
level?: string
id?: number | string
view?: number
read?: boolean
pageParam?: string
}) => {
// 数据获取
const res = await apiClient.entries.$post({
json: {
publishedAfter: pageParam as string,
read,
...getEntriesParams({
level,
id,
view,
}),
},
})
const data = await res.json()
if (data.data) {
data.data.forEach((entry: EntryModel) => {
// 把数据更新到 store 的 data map 里
get().upsert(entry.feeds.id, entry)
})
}
return data
},
// 定义一个数据更新方法
upsert(feedId: string, entry: EntryModel) {
set((state) =>
produce(state, (draft) => {
draft.entries[entry.entries.id] = entry
return draft
}),
)
},
}))
另外我们可以写一个 hook 去根据 id 获取数据。
export const useEntry = (entryId: string) => useEntryStore((state) => state.entries[entryId])
现在我们的数据由 Zustand 托管了,我们需要修改原先直接从 React Query 消费的数据的代码。
例如原先子组件直接对 React Query 的数据进行透传的 props,我们可以修改成 id 作为 props,然后再从 store 中获取数据。
乐观更新
现在我们不再需要使用非常繁琐的 React Query 提供的 setData
去更新数据了。我们只需要根据当前的数据 Id 去更新相应的数据。
export const useEntryStore = create<EntryState & EntryActions>((set, get) => ({
entries: {},
// ...
optimisticUpdate(entryId: string, changed: Partial<EntryModel>) {
set((state) =>
produce(state, (draft) => {
const entry = draft.entries[entryId]
if (!entry) return
Object.assign(entry, changed)
return draft
}),
)
},
markRead: (feedId: string, entryId: string, read: boolean) => {
get().optimisticUpdate(entryId, {
read,
})
},
}))
例如这里我们可以写一个 markRead
方法专门去做这部分的逻辑。
整体数据同步
当 store 中的 fetch 方法执行时,会自动把远程服务器的数据覆写到 store 的 data map 中。因此,我们修改 useQuery 中的 queryFn,让其指向 store.fetchEntries
。
export const entries = {
entries: ({
level,
id,
view,
read,
}: {
level?: string
id?: number | string
view?: number
read?: boolean
}) =>
defineQuery(
["entries", level, id, view, read],
async ({ pageParam }) =>
// 这里为接口
entryActions.fetchEntries({
level,
id,
view,
read,
pageParam: pageParam as string,
}),
),
}
useInfiniteQuery(entries.entries({ level, id, view, read }), {
enabled: level !== undefined && id !== undefined,
getNextPageParam: (lastPage) => {
if (!lastPage.data?.length) {
return null
}
return lastPage.data.at(-1)!.entries.publishedAt
},
initialPageParam: undefined,
})
整体数据流
后记
这个设计在 Follow 中,具体参考下面 pr 的修改。
Follow 是一款正在开发中的 RSS 信息流浏览器,敬请期待吧。