logo
Published on

Reactの状態管理ライブラリを比較してみる

Authors

実務で状態管理ライブラリの選定を行う機会があったので、その時に比較したライブラリをまとめてみます。

そもそも状態管理ライブラリが必要になる理由

Reactのコンポーネントツリーが深くなると、propsのバケツリレーが発生します。例えば、親コンポーネントから孫の孫のコンポーネントまでデータを渡すためには、中間のコンポーネントすべてでpropsを経由する必要が出てきます。これにより、コードが複雑になり、メンテナンスが難しくなります。

また、複数のコンポーネント間で共有したい状態がある場合、その状態をどこに置くかという問題も出てきます。共通の親コンポーネントに常態を持たせると、その親コンポーネントが再レンダリングされた時に、関係のない子コンポーネントまで再レンダリングされてしまう可能性があります。

こうした課題を解決するために、状態管理ライブラリが利用されます。

Context API

Context APIはReact16.3で導入された、コンポーネントツリー全体で状態を共有するための仕組みです。ProviderとConsumer(またはuseContextフック)を使って、prop drillingを避けることができます。

優れた点としては、追加のライブラリが不要ということです。テーマや、言語設定、認証済みユーザー情報など、アプリケーション全体で参照する必要があるが、頻繁には変更されない状態の管理に適しています。

注意点としては、Contextの値が変更されると、そのContextを使用している全てのコンポーネントが再レンダリングされます。つまり、頻繁に更新される状態をContextで管理すると、パフォーマンスに悪影響を与える可能性があります。

Redux

ReduxはFluxアーキテクチャの影響を受けており、単一方向のデータフローとimmutable(不変)な状態更新を特徴としています。

Reduxの特徴的な概念は、アプリケーション全体の状態を単一のstateで管理することです。常態を更新するには、必ずactionをdispatchし、reducerがそのactionを受け取って新しい状態を返します。この流れが、一方向に固定されているため、状態の変化を追跡しやすく、デバッグもしやすくなります。

もう一つの特徴は、開発者ツールの充実さです。Redux DevToolsを使うことで、dispatchされたactionの履歴を見たり、特定の時点の状態に戻ったりできます。これは、大規模アプリケーションのデバッグに役立ちます。

デメリットとしては、提携コードが多いと言われています。要はシンプルではないということです。

このデメリットを解決するために、Redux Toolkitという公式推奨のライブラリが提供されています。

Zustand

Zustandは、極限までにシンプルにしたライブラリです。

特徴としては、使いやすさです。create関数でstoreを作成し、そのフックをコンポーネント内で呼び出すだけで使用できます。Context Providerを囲む必要もありません。

また、Reduxと違って単一のstoreである必要もありません。機能ごとにstoreを分けることもできますし、1つのstoreにまとめることもできます。この柔軟性が中小規模のアプリケーション開発に適しています。

Jotai

Jotaiは、Recoilの影響を受けて開発された、atomicな状態管理という新しいアプローチです。

特徴としては、atomという概念です。atomは状態の最小単位で、それぞれが独立した状態を持ちます。useStateとにていますが、atomはグローバルにアクセス可能で、コンポーネント間で共有することもできます。また、atomから派生したatomも作成することができ、複雑な状態をシンプルに管理できます。

そして、重要なのがボトムアップな設計思想を持っているということです。Reduxのように最初から大きなstore構造を設計するのではなく、必要なatomを必要な時に追加していく形で開発を進められます。

状態を細かい単位で管理したい場合、状態間の依存関係が複雑な場合、またはボトムアップで柔軟に開発したい場合に適しています。

Recoil

Jotaiと同様にatom無状態管理のアプローチを採用しています。

特徴は、Reactの並行レンダリングを見据えた設計になっている点です。また、selectorという概念を使って、atomから派生した値を効率的に研鑽できます。selectorは依存するatomが変更された時だけ再計算されるため、パフォーマンスの最適化が自動的に行われます。

5つのライブラリの比較

アーキテクチャの観点

  • Redux: 中央集権的なアプローチ。全ての状態が単一のstoreに集約され、変更はactionとreducerを通じて行われる。最初にある程度の状態構造を設計してから実装を始める必要がある。
  • Zustand: storeベースのアプローチ。状態をstoreに集約し、必要な部分を取り出して使用。一つの大きなstoreでも、機能ごとに複数のstoreでも、柔軟に選択できる。
  • Jotai: atomベースの分散的アプローチ。必要な場所に必要なatomを配置し、ボトムアップで開発を進められる。atomを組み合わせて複雑な状態を構築。
  • Recoil: Jotaiと同様にatomベースの分散的アプローチ。atomには一意なキーが必要。atomFamilyで動的にatomを生成できる。
  • Context API: Provider単位で状態を管理。必要に応じて複数のContextを作成できる中間的な立ち位置。

学習コスト

  • Context API: Reactの標準機能のため、追加の学習コストはほとんどない。ただし、パフォーマンス最適化や適切な分割には実践的な経験が必要。
  • Zustand: APIが非常にシンプルで、ドキュメントを読めば数時間で基本的な使い方を習得できる。create関数でstoreを作り、hooksとして使うだけ。
  • Jotai: atomという概念を理解すれば、あとはuseStateと同じように使用できる。derived atomや非同期atomなど高度な機能は段階的に学習可能。
  • Recoil: Jotaiと似ているが、キーの管理、selector、atomFamilyなど、やや複雑な概念がある。それでも全体としては学びやすい。
  • Redux: 最も学習すべき概念が多い。action、action creator、reducer、dispatch、middlewareを理解し、データフロー全体を把握する必要がある。Redux Toolkitで以前より簡単になったが、学習コストは依然として高い。

パフォーマンス

  • Context API: Providerのvalueが変更されると、そのContextを使用している全てのコンポーネントが再レンダリングされる。頻繁に更新される状態には向かない。Contextの分割やReact.memoで対処が必要。
  • Redux: selectorを使って必要な状態だけを購読することで、効率的な再レンダリングを実現。reselectライブラリでselectorの結果をメモ化し、不要な再計算を避けられる。Redux ToolkitにはcreateSelectorが組み込まれている。
  • Zustand: selectorによる最適化が可能。storeから特定の部分だけ取り出すことで、その部分が変更された時だけ再レンダリング。内部でshallow comparison(浅い比較)が行われるため、オブジェクトや配列でも効率的。
  • Jotai: atom単位で依存関係が自動的に追跡されるため、最も細かい粒度での最適化が可能。特定のatomを使っているコンポーネントだけが再レンダリング。derived atomは依存するatomが変わった時だけ再計算。
  • Recoil: Jotaiと同様、atom単位で依存関係が自動追跡される。selectorは依存する値が変更された時だけ再計算され、自動的にパフォーマンスが最適化される。

開発者体験(DevTools)

  • Redux: Redux DevToolsが非常に強力。dispatchされた全てのactionの履歴を確認でき、タイムトラベルデバッグが可能。actionの履歴をエクスポートしてバグレポートに添付できる。
  • Zustand: devtools middlewareでRedux DevToolsと統合可能。actionの概念がないため、Reduxほど詳細な履歴は残らないが、状態のスナップショットを見ながらデバッグできる。
  • Jotai: 専用のDevToolsあり。atomの現在の値を確認したり、依存関係のグラフを可視化できる。Redux DevToolsほど成熟していないが、開発中のデバッグには十分。
  • Recoil: DevToolsは実験的な段階。Snapshot機能でプログラマティックに状態を検査でき、テストやデバッグに活用できる。
  • Context API: Reactの標準DevToolsで状態を確認できる。専用のツールはないため、複雑な状態変化の追跡は難しい。

型安全性(TypeScript)

  • Zustand: 型定義がシンプル。storeの型を定義すれば、hooksに自動的に反映される。selectorを使う場合も、返される値の型が正しく推論される。複雑な型定義は不要。
  • Jotai: 型推論が非常に優れている。atomを定義する時に初期値の型から自動的に推論され、useAtomでも正しい型が得られる。derived atomの型も依存するatomから自動推論。
  • Recoil: TypeScriptサポートはあるが、キーを文字列で指定する必要があり、型とキーの対応を手動で管理する必要がある。適切な型定義を行えば型安全に使える。
  • Redux: Redux ToolkitはTypeScriptサポートが充実。createSliceで定義したstateやactionの型が自動推論される。ただし、thunkやsagaの型定義はやや複雑になることがある。
  • Context API: Contextを作成する時に型を明示する必要がある。その後は型安全に使えるが、初期値の扱いなど、やや煩わしい部分がある。

エコシステムと拡張性

  • Redux: 最も成熟したエコシステム。redux-thunk、redux-saga等の非同期処理middleware、reselectのような最適化ライブラリ、RTK Queryのようなデータフェッチソリューションなど、様々なツールが揃っている。長年の実践例やベストプラクティスが蓄積されている。
  • Zustand: 比較的新しいが、必要十分な機能を持つ。persist middleware(localStorage連携)、devtools middleware(Redux DevTools統合)など実用的なmiddlewareが用意されている。小さくシンプルなため、自分でmiddlewareを作ることも容易。
  • Jotai: 成長中のエコシステム。jotai/utilsパッケージによく使われるパターンを実装したutility関数が含まれる。React QueryやZustandとの統合も可能。
  • Recoil: Metaが開発しているため、Metaのエコシステムとの統合が期待されるが、現時点ではエコシステムは限定的。サードパーティのライブラリもそれほど多くない。
  • Context API: Reactの標準機能のため、他のReactライブラリとの互換性は保証される。Context API自体を拡張するというより、他のライブラリと組み合わせて使うことが一般的。

バンドルサイズ

  • Context API: Reactの標準機能のため、追加のバンドルサイズは発生しない。
  • Zustand: 非常に小さく、gzip圧縮後で約1KB。
  • Jotai: 小さく、約3KB。
  • Recoil: やや大きく、約20KB。
  • Redux: Redux Toolkit(Redux本体やImmerを含む)で約45KB。提供される機能を考えると妥当なサイズ。

その他の特徴

  • Zustand: Reactの外からでもstoreにアクセス可能。WebSocketのイベントハンドラや外部ライブラリとの統合が容易。
  • Jotai: atomが自動的にガベージコレクションされる。どのコンポーネントからも参照されなくなったatomは自動的にメモリから解放される。
  • Recoil: React並行レンダリング(Concurrent Mode)を見据えた設計。将来的なReactの進化に対応できる可能性がある。
  • Redux: action履歴によりユーザー操作の記録・再生が可能。エラーレポートへの添付や監査にも活用できる。

選定基準

プロジェクトの規模

  • 非常に小規模(数ページのランディングページ、簡単なツール): Context APIで十分。認証状態やテーマ設定など限られた状態を管理するだけなら、わざわざライブラリを追加する必要はない。
  • 小〜中規模(10〜30ページ程度の業務アプリケーション、ダッシュボード): ZustandまたはJotaiが良い選択肢。状態をある程度構造化したい場合はZustand、ボトムアップに必要な状態を追加していきたい場合はJotai。
  • 中〜大規模(複雑なビジネスロジックを持つSaaSアプリケーション): Zustand、Redux、またはJotai。チームの規模や開発スタイルが判断材料になる。
  • 大規模(複数チームが協力する大規模プロジェクト): Reduxの明示的なactionとreducerのパターンが価値を発揮。コードレビューがしやすく、Redux DevToolsによる強力なデバッグ機能が複雑なバグの追跡に役立つ。

状態の性質

  • UIの状態(モーダルの開閉、タブの選択など): 基本的にuseStateで管理するのが最適。コンポーネント固有の状態をグローバルに管理すると複雑になる。
  • クライアント側の状態(フォームの入力内容、一時的な変更など): 状態管理ライブラリの主な対象。複数のコンポーネントにまたがる場合、Zustand、Jotai、Reduxなどを使用。
  • サーバーから取得した状態(ユーザー情報、記事リストなど): React QueryやSWRのような専用ライブラリの使用を推奨。これらはキャッシュ、再取得、楽観的更新を自動処理。Reduxを使っている場合はRTK Queryでサーバー状態を管理することも可能。

パフォーマンス要件

  • 頻繁に更新される状態(リアルタイムデータ、チャット、株価表示など): JotaiやRecoilのatomicなアプローチが有利。更新される状態を細かいatomに分割することで、関係のないコンポーネントの再レンダリングを最小限に抑えられる。Zustandでもselectorを使って同様の最適化が可能。
  • Context APIは頻繁な更新には不向き: 頻繁な更新で多くのコンポーネントが再レンダリングされ、パフォーマンス問題が顕在化する。
  • 細かい粒度での最適化: atom単位で依存関係が自動追跡されるJotai/Recoilが最も効率的。

開発チームの経験・スキルセット

  • Reduxの経験が豊富なチーム: Redux Toolkitを選ぶことで、その経験を活かせる。新しいライブラリを学ぶコストを避け、確立されたパターンで開発可能。
  • Reduxの経験がないチーム: 時間があり大規模なアプリケーションになる予想なら、Reduxを学ぶ投資をする価値あり。素早く開発を始めたい、または中規模で収まる見込みなら、ZustandやJotaiが適している。
  • TypeScriptを積極的に活用するチーム: Jotaiの優れた型推論は大きな利点。Zustandも良好だが、Jotaiほどスムーズではない。
  • 小規模チームで素早く開発: Zustandのboilerplateの少なさと直感的な使いやすさが開発速度を保つ。学習コストが低いため、新メンバーもすぐにキャッチアップ可能。

メンテナンスのしやすさ

  • Redux: 明示的なactionにより、1年後、2年後にコードを見返しても理解しやすい。actionのログを見れば操作が一目瞭然。reducerを見れば、actionが状態にどう影響するかがわかる。
  • Zustand: 状態更新のロジックがstoreに集約されているため比較的理解しやすい。ただし、複数の場所から状態を更新できるため、適切な命名規則と更新ロジックの集約が必要。
  • Jotai/Recoil: atomが増えると依存関係の全体像把握が難しくなることがある。ドキュメントや図での整理が重要。Jotaiには依存関係を可視化するツールがあるため活用可能。
  • Redux: action履歴により監査やトラブルシューティングが容易。エンタープライズアプリケーションで重要。

将来的な拡張性・移行コスト

  • Context APIからの移行: 後でReduxやZustandに移行することは可能だが、コンポーネント構造を大きく変更する必要があることがある。Context依存のコンポーネントが多い場合、影響範囲が広い。
  • ZustandやJotaiからReduxへの移行: paradigmが異なるため、単純な置き換えではなく、アーキテクチャの再設計が必要。移行コストが高い。
  • 推奨アプローチ: プロジェクトの成長を見越して、最初からある程度スケールできるライブラリを選んでおくことが賢明。ただし過剰な設計も避け、現時点のニーズと近い将来の成長のバランスを取ることが大切。

その他の考慮事項

  • サーバー状態とクライアント状態の分離: サーバー状態は専用ライブラリ(React Query、SWR)で管理し、クライアント状態のみを状態管理ライブラリで扱うことを検討。ただしReduxを使う場合はRTK Queryで統合管理も可能。
  • Recoilの注意点: 現在も実験的(experimental)な段階。本番環境での使用には慎重な判断が必要。安定性を重視するならJotaiの方が無難。
  • 一貫性の重要性: 複数の状態管理ライブラリを混在させると、チームの学習コストや保守性に影響。既存プロジェクトでは一貫性を保つことを優先。
  • プロトタイプや個人プロジェクト: 完璧な選択より素早く動くものを作ることが優先。ZustandやJotaiで素早く試行錯誤。