banner
innei

innei

写代码是因为爱,写到世界充满爱!
github
telegram
twitter

A frontend data management approach suitable for Zustand and React Query

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.

image

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.

https://github.com/RSSNext/follow/blob/9920c46fd677cb8c9e88e62a3ce75c4a3a73fa03/src/renderer/src/hooks/useUpdateEntry.tsx

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.

image

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


Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.