banner
innei

innei

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

Zustand と React Query に適したフロントエンドデータ管理方法

上記の文章では、React Query が管理するデータが複雑または冗長すぎる場合、フロントエンドでのデータの楽観的更新が非常に複雑で制御不能になることが言及されています。

Note

楽観的更新とは、フロントエンドがデータ操作を行う際に、API を介してサーバーにデータを送信し、その後の UI 上のデータ更新をフロントエンドが行うことを指します。サーバーの応答を待つ必要はありません。この方法は、ユーザーにとってほとんど感じられません。

ビジネスの説明#

現在、私たちはこのようなシナリオを持っています。データ構造はこのようなものです。

image

インターフェースはページネーションされており、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 の 2 つの方法を使用すると、最終的なデータ構造が異なりますが、私たちのデータは同じである可能性があります。

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

Zustand + React Query#

現在、私たちは React Query によるデータ管理を放棄し、Zustand の方法を使用します。

バックエンドのテーブルと同様に、フロントエンドでもデータのマッピングテーブルを作成します。上の図のデータセットを利用して、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) => {
        // データをストアのデータマップに更新
        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
      }),
    )
  },
}))

また、id に基づいてデータを取得するための hook を書くことができます。

export const useEntry = (entryId: string) => useEntryStore((state) => state.entries[entryId])

現在、私たちのデータは Zustand によって管理されています。私たちは元々React Query から直接データを消費していたコードを修正する必要があります。

例えば、元々子コンポーネントが React Query のデータを透過的に props として渡していた場合、id を props として渡し、ストアからデータを取得するように変更できます。

image

楽観的更新#

現在、私たちは非常に煩雑な 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メソッドを作成してこの部分のロジックを処理できます。

全体データ同期#

ストアの fetch メソッドが実行されると、リモートサーバーのデータが自動的にストアのデータマップに上書きされます。したがって、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,
  })

全体データフロー#

https://cdn.jsdelivr.net/gh/innei/shiro-remote-components@main/excalidraw/2-data-flow-query-and-zustand.json

後記#

この設計は Follow の中で、具体的には以下の pr の変更を参考にしています。

https://github.com/RSSNext/follow/pull/28

Follow は現在開発中の RSS 情報流ブラウザです。ご期待ください。

この記事は Mix Space によって xLog に同期更新されました。 原始リンクは https://innei.in/posts/tech/data-management-approach-for-zustand-and-react-query

読み込み中...
文章は、創作者によって署名され、ブロックチェーンに安全に保存されています。