banner
Jim Luo

Jim Luo

A normal software engineer and an enthusiast in computer graphics and data visualization.
twitter
github
bilibili
nintendo switch
playstation

ZustandとReact Context

本文は元の記事の翻訳です。見落としや誤りがあれば指摘してください。

アーチの下に座っている zustand のクマのロゴ

Zustand は、優れたグローバルクライアントステート管理ライブラリです。シンプルで高速で、パッケージサイズも小さいです。ただ、私があまり好きではない点があります:

ストアはグローバルです

それはいいですか?でもそれがグローバルステート管理の意味ですよね?アプリケーションのどこからでもステートを利用できるようにするためですよね?

時にはそう思います。しかし、最近の zustand の使用経験を振り返ると、ステートがコンポーネントのサブツリーでのみグローバルに利用可能である必要があることに気づくことがよくあります。zustand を使用すると、複数の小さなストアを機能ごとに作成することができます。なので、パネルのルートでのみパネルフィルタのステートを使用する必要がある場合、なぜパネルフィルタストアをグローバルに利用する必要があるのでしょうか?もちろん、痛みなく使用することもできますが、グローバルステートストアを使用することにはいくつかの欠点があることに気づきました:

Props を使用した初期化#

グローバルステートストアは React コンポーネントのライフサイクルの外部で作成されるため、ステートストアを初期化するために prop の値を利用することはできません。グローバルステートストアでは、既知のデフォルトステートを使用して作成し、useEffectを使用して props からストアへのステートの同期を行う必要があります:

const useBearStore = create((set) => ({
  // ⬇️ デフォルト値で初期化
  bears: 0,
  actions: {
    increasePopulation: (by) =>
      set((state) => ({ bears: state.bears + by })),
    removeAllBears: () => set({ bears: 0 }),
  },
}))

const App = ({ initialBears }) => {
  //😕 initialBearsをストアに書き込む
  React.useEffect(() => {
    useBearStore.set((prev) => ({ ...prev, bears: initialBears }))
  }, [initialBears])

  return (
    <main>
      <RestOfTheApp />
    </main>
  )
}

useEffectを書くのは面倒ですし、以下の 2 つの理由から望ましくない結果になります:

  1. initialBearsの値をbears: 0で初期化し、正しいinitialBearsの値が割り当てられた後に<RestOfTheApp />をレンダリングすると、複数回のレンダリングが発生します。
  2. ステートストアを初期化するためにinitialBearsを使用する必要はありません - 同期のためにのみ使用します。したがって、initialBearsが変更されると、ステートストアが同期して更新されることになります。

テスト#

zustand のテストドキュメントは非常に混乱し、複雑です。これらのテストは zustand をモックしたり、ステートストアをリセットしたりすることに関連しています。これはすべて、ステートストアがグローバルであるという事実に起因していると思います。それをコンポーネントのサブツリーに制限すると、テスト中にステートストアを完全に分離できるため、これらの「ワークアラウンド」は必要ありません。

再利用性#

すべてのステートストアがアプリケーションや特定のルートで一度だけ使用されるシングルトンではありません。時には、zustand のステートストアをコンポーネント内で再利用したいことがあります。その一例は、過去に私が設計した複雑なマルチセレクトコンポーネントです。内部のマルチセレクトのステートを管理するために、ローカルステートを使用して React Context を介して上位から下位に渡します。選択されたオプションが 50 以上になると、パフォーマンスが低下します。この問題について私はツイートしました。

zustand のステートストアがグローバルである場合、コンポーネントを複数回インスタンス化することなく、それぞれのコンポーネントでステートストアを共有および上書きすることはできません。


興味深いことに、これらの問題を解決する方法があります:

React Context#

React Context は、ステート管理ツールとして使用する方法としては滑稽で皮肉ですが、Context を使用してステートストアを管理することはお勧めしません。これにより、ステートストアのインスタンスを共有するだけでなく、ステートストア内のステート自体を共有することができます。

概念的には、これは React Query の<QueryClientProvider>の実装や、reduxの単一のステートストアの実装と同じです。ステートストアのインスタンスは静的なシングルトンであり、頻繁に変更されることはないため、React Context に簡単に配置でき、再レンダリングの問題は発生しません。そして、引き続き zustand を使用してステートストアを作成することができます。具体的な実装は次のようになります:

v5 の構文
この記事では、v5 の構文を使用して zustand と React Context を統合する方法を示します。以前のバージョンの zustand には、zustand/contextからエクスポートされる明示的なcreateContext関数がありました。

import { createStore, useStore } from 'zustand'

const BearStoreContext = React.createContext(null)

const BearStoreProvider = ({ children, initialBears }) => {
  const [store] = React.useState(() =>
    createStore((set) => ({
      bears: initialBears,
      actions: {
        increasePopulation: (by) =>
          set((state) => ({ bears: state.bears + by })),
        removeAllBears: () => set({ bears: 0 }),
      },
    }))
  )

  return (
    <BearStoreContext.Provider value={store}>
      {children}
    </BearStoreContext.Provider>
  )
}

主な違いは、インスタンスを作成するためにデフォルトのcreate関数ではなく、純粋な zustand のcreateStore関数を使用していることです。また、どこでもこれを行うことができます - コンポーネント内部でも構いません。ただし、ステートストアの作成が一度しか行われないことを確認する必要があります。これは ref を使用して解決することもできますが、私はuseStateを使用する方が好きです。なぜそうするのかについては、別の記事で説明しています。

コンポーネント内部でステートストアを作成するため、initialBearsなどの props を渡す必要はありません。useStateの初期化関数は 1 回しか呼び出されないため、prop の更新はステートストアに伝播されません。そして、ステートストアのインスタンスを作成し、単純な React Context に渡します。ここでは zustand の制約はありません。


その後、ステートストアから値を取得して消費する場合には、このコンテキストが必要になります。そのためには、storeselectoruseStoreフックに渡す必要があります。これは、次のようなカスタムフックに対応する最適な抽象化です:

const useBearStore = (selector) => {
  const store = React.useContext(BearStoreContext)
  if (!store) {
    throw new Error('Missing BearStoreProvider')
  }
  return useStore(store, selector)
}

そして、useBearStoreフックを使用し、いくつかのアトミックセレクタを使用してカスタムフックをエクスポートすることで、以前と同じように使用できます:

export const useBears = () => useBearStore((state) => state.bears)

グローバルな zustand ステートストアを作成するよりも少しコードが増えますが、次の 3 つの問題を解決します:

  1. 例に示したように、props を使用してステートストアを初期化することができます。コンポーネントツリー内部で作成されたためです。
  2. テストは簡単になります。BearStoreProviderを含むコンポーネントをレンダリングするか、テスト用のコンポーネントをレンダリングするかを選択できます。これらのシナリオでは、作成済みのステートストアが完全に分離されるため、テスト間でステートストアをリセットする必要はありません。
  3. これで、コンポーネントがBearStoreProviderをレンダリングして、その子コンポーネントにカプセル化された zustand ステートストアを提供することができます。ページ内で自由にこのコンポーネントをレンダリングできます - 各インスタンスは独自のステートストアを持ち、再利用が可能になります。

最後に、zustand のドキュメントは、ステートストアにアクセスするために Context Provider が必要ないと自慢していますが、ステートストアの作成と React Context の統合方法を理解することは重要だと思います。これにより、カプセル化されたステートストアを再利用する必要があるシナリオを簡単に処理できます。個人的には、この抽象化の概念を使用する頻度がグローバルな zustand ステートストアよりも多いです。😄


これが今日話したいことです。質問があれば、Twitterで私に連絡するか、コメント欄にコメントしてください。⬇️

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