In the above article, it is mentioned that when the data managed by React Query becomes too complex or redundant, subsequent optimistic updates of data in the frontend will become very complicated and uncontrollable.
Note
Optimistic updates refer to the frontend submitting data to the server through the API, and then the frontend provides the updates on the UI without waiting for the server's response. This approach is almost imperceptible to users.
Business Description#
Now we have a scenario where the data structure is similar to this.
The interface is paginated, and combined with React Query's InfiniteQuery, we will use it like this.
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 }) =>
// The interface is here
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,
})
We can use React Query to consume the data.
Now we need to update the "read" field of this data. At this time, we need to use optimistic updates because the markRead operation is a frequent operation. We cannot re-fetch the backend data to overwrite the current data after each operation. This not only slows down the feedback on the UI, but also causes browsing waste.
Below is how we update the data using React Query's built-in Cache Store, but this is not only inefficient, but also prone to errors due to insufficient type inference, making it difficult to maintain in the later stages. Moreover, in React Query, using useQuery and useInfiniteQuery will result in different data structures, but our data may be the same.
Zustand + React Query#
Now we abandon using React Query to manage data and instead use Zustand.
Just like the backend table, we also establish a mapping table for data in the frontend. With the data set shown above, we can use Zustand to establish an entryId -> entry table.
export const useEntryStore = create<EntryState & EntryActions>((set, get) => ({
entries: {}, // entryId -> entry table
fetchEntries: async ({
level,
id,
view,
read,
pageParam,
}: {
level?: string
id?: number | string
view?: number
read?: boolean
pageParam?: string
}) => {
// Data retrieval
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) => {
// Update the data to the store's data map
get().upsert(entry.feeds.id, entry)
})
}
return data
},
// Define a method to update data
upsert(feedId: string, entry: EntryModel) {
set((state) =>
produce(state, (draft) => {
draft.entries[entry.entries.id] = entry
return draft
}),
)
},
}))
In addition, we can write a hook to get data based on the id.
export const useEntry = (entryId: string) => useEntryStore((state) => state.entries[entryId])
Now our data is managed by Zustand, and we need to modify the code that directly consumes the data from React Query.
For example, the props that were directly passed through to the child component from React Query can be modified to pass the id as props and then retrieve the data from the store.
Optimistic Updates#
Now we no longer need to use the cumbersome setData
provided by React Query to update the data. We only need to update the corresponding data based on the current data 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,
})
},
}))
For example, here we can write a markRead
method specifically for this logic.
Overall Data Synchronization#
When the fetch method in the store is executed, the data from the remote server will be automatically overwritten to the data map in the store. Therefore, we modify the queryFn in useQuery to point to 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 }) =>
// The interface is here
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,
})
Overall Data Flow#
https://cdn.jsdelivr.net/gh/innei/shiro-remote-components@main/excalidraw/2-data-flow-query-and-zustand.json
Afterword#
This design is used in Follow. Please refer to the modifications in the following PR.
https://github.com/RSSNext/follow/pull/28
Follow is an RSS feed reader currently under development. Stay tuned.
This article is synchronized and updated to xLog by Mix Space.
The original link is https://innei.in/posts/tech/data-management-approach-for-zustand-and-react-query