本文は元の記事の翻訳です。見落としや誤りがあれば指摘してください。
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 つの理由から望ましくありません:
initialBears
の値を使用して<RestOfTheApp />
を初期化し、その後で正しいinitialBears
の値が設定されると複数回レンダリングされることがあります。- ステートを初期化するために
initialBears
を使用する必要はありません - 同期に使用するだけです。したがって、initialBears
が変更されると、ステートが同期されることがあります。
テスト#
zustand のテストドキュメントは非常に混乱し、複雑です。これらのテストは zustand をモックしたり、ストアのリセットなどを行うものです。これはすべて、ストアがグローバルであるという事実に起因していると思います。コンポーネントのサブツリーに制限されたストアをレンダリングしながら、それらのテストを実行することができるため、テストは非常に簡単になります。これにより、さまざまな「ハック」を必要としません。
再利用性#
すべてのストアがアプリケーションや特定のルートで一度だけ使用されるシングルトンではないというわけではありません。時には、zustand のストアをコンポーネント内で再利用したいことがあります。過去に私が設計したシステムでは、複雑なマルチセレクトコンポーネントがありました。内部のマルチセレクトの状態を管理するために、ローカルステートを使用して React Context を介して上位コンポーネントに渡しました。選択されたオプションが 50 以上ある場合、パフォーマンスが低下しました。これについて私はツイートしました。
zustand のストアがグローバルである場合、コンポーネントを複数回インスタンス化することなく、それぞれのコンポーネントで異なる状態を共有およびオーバーライドすることはできません。
興味深いことに、これらの問題を解決する方法があります:
React Context#
React Context は、ステート管理ツールとして使用するとすぐに上記の問題が発生するため、この方法が滑稽で皮肉に思えます。しかし、私はそれをお勧めしません。これにより、React 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
関数を使用してストアを作成することです。また、どこでもこれを行うことができます - コンポーネント内部でも構いません。ただし、ストアを作成する動作が 1 回しか発生しないことを確認する必要があります。これは ref を使用して解決することもできますが、私はuseState
を使用する方が好きです。なぜそうするのかについては、別の記事で説明しています。
コンポーネント内部でストアを作成するため、initialBears
などの props を渡す必要がなくなります。useState
の初期化関数は 1 回しか呼び出されないため、prop の更新はストアに伝播しません。そして、ストアをインスタンス化して単純な React Context に渡します。ここでは、zustand の制約はありません。
その後、値を消費するためにストアからいくつかの値を取り出したい場合、このコンテキストが必要になります。そのためには、store
とselector
をuseStore
フックに渡す必要があります。これは、次のようなカスタムフックに対応します:
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 つの問題を解決します:
- 上記の例に示すように、props を使用してストアを初期化することができます。コンポーネントツリー内部で作成されたためです。
- テストは簡単になります。
BearStoreProvider
を含むコンポーネントをレンダリングするか、テスト用のコンポーネントをレンダリングするかを選択できます。これらのシナリオでは、作成済みのストアが完全に分離されるため、テスト間でストアをリセットする必要はありません。 - これで、コンポーネントは
BearStoreProvider
をレンダリングして、子コンポーネントにカプセル化された zustand ストアを提供できます。ページ内で自由にこのコンポーネントをレンダリングできます - 各インスタンスには独自のストアがあり、再利用が可能になります。
最後に、zustand のドキュメントは、ステートストアにアクセスするために Context Provider を使用する必要がないと自慢していますが、ステートストアの作成と React Context の統合方法を理解することは重要だと思います。これにより、カプセル化された再利用可能なシナリオを扱う際に非常に便利です。私個人としては、この抽象化の概念を使用する頻度がグローバルな zustand ストアよりも高いです。😄
以上が今日話したいことです。ご質問がある場合は、Twitterでお気軽にお問い合わせください。または、コメント欄にコメントを残してください。⬇️