

執筆
中川 幸哉
有限会社 WINGSプロジェクトが運営する、テクニカル執筆コミュニティ(代表:山田祥寛)に所属するテクニカルライター。新潟県上越市出身。会津大学コンピュータ理工学部卒業後、現在は新潟市に在住。ReactやAndroidを軸に、モバイルアプリ開発やWebサイト制作、Webメディア編集部の業務改善や、プログラミング技術記事の執筆等に携わっている。著書に『たった1日で基本が身に付く! Androidアプリ開発超入門』、『基礎から学ぶ React Native入門』

監修
山田 祥寛
静岡県榛原町生まれ。一橋大学経済学部卒業後、NECにてシステム企画業務に携わるが、2003年4月に念願かなってフリーライターに転身。Microsoft MVP for Visual Studio and Development Technologies。執筆コミュニティ「WINGSプロジェクト」代表。主な著書に『「独習」シリーズ』、『これからはじめるReact実践入門』、『改訂3版 JavaScript本格入門』、他。
はじめに:「どの状態管理を使うべきか」という悩み
「このプロジェクトの状態管理、どうする?」
プロジェクトキックオフのたびに、この議論が起こります。Redux Toolkit? Zustand? Jotai? それともContext APIで十分? サーバーデータはTanStack Queryを使うべき? 各ライブラリのドキュメントを読んでも、「結局どれを選べばいいのか」という判断基準が見えてこない。そして、プロジェクトごとに異なるアプローチを採用した結果、コードベース全体の一貫性が失われていく…こうした状況は、多くの開発現場で起きています。
前回は、Propsのパフォーマンス最適化とProps Drillingの回避策としてContext APIを紹介しました。しかし、アプリケーションの規模が大きくなるにつれ、より体系的な状態管理アプローチが必要になります。今回は、状態の種類に応じた実践的なライブラリ選択指針と、状態管理パターンの進化について解説します。
状態管理の基本原則
まず、状態管理の基本を簡潔に確認しましょう。Reactアプリケーションにおける状態は、3つのカテゴリに分類できます。
ローカル状態は、特定のコンポーネント内でのみ使用される状態です。カウンターの値、モーダルの開閉状態、フォームの入力値など、そのコンポーネントの責務範囲内で完結する情報がこれにあたります。
グローバル状態は、アプリケーション全体で共有される状態です。ユーザー認証情報、アプリケーションのテーマ設定、複数の画面をまたいで使われるショッピングカートの内容などが該当します。
ローカルとグローバルの間には「複数コンポーネント間で共有されるが、アプリ全体ではない状態」も存在します。例えば特定のフォーム内でのみ共有される入力状態などです。この場合は「小さなグローバル状態」として、必要最小限の範囲にProviderを配置するアプローチが有効です。
サーバー状態は、バックエンドAPIから取得されるデータで、キャッシング、再検証、楽観的更新といった特有の要件を持ちます。ユーザー一覧、商品情報、投稿データなど、サーバーが真の情報源となるデータです。
これらの状態を適切に分類し、それぞれに適したツールを選択することが、保守性の高い状態管理の第一歩となります。

ユースケース別ライブラリ選択指針
それでは、具体的なユースケースごとに、どのライブラリを選択すべきかを見ていきましょう。
ローカル状態:useState vs useReducer
ローカル状態の管理には、ReactのビルトインフックであるuseStateとuseReducerを使い分けます。
useStateは、シンプルな状態管理に適しています。カウンター、トグル、単一の入力値など、状態の更新パターンが直感的な場合に最適です。
// ✅ useState:シンプルな状態管理 function Counter() { const [count, setCount] = useState(0); return <button> setCount(count + 1)}>Count: {count}</button>; }
一方、useReducerは複雑な状態遷移の管理に向いています。複数の関連する状態を持つフォーム、ステップごとに進むウィザードUI、複雑なビジネスロジックを持つ状態など、更新パターンが多岐にわたる場合に力を発揮します。
// ❌ useState:複雑な状態管理では混乱しやすい function ComplexForm() { const [name, setName] = useState(''); const [email, setEmail] = useState(''); const [errors, setErrors] = useState({}); const [isSubmitting, setIsSubmitting] = useState(false); // 状態更新が複数に散らばり、管理が困難 } // ✅ useReducer:状態遷移を明確に定義 function ComplexForm() { const [state, dispatch] = useReducer(formReducer, initialState); // 例:dispatch({ type: 'SET_FIELD', field: 'name', value: 'John' }) }
dispatchによる状態更新では、アクションオブジェクトを通じて状態遷移が集約され、変更の追跡やデバッグが容易になります。ただし、シンプルな状態管理ではreducerの定義やアクション型の宣言などボイラープレートが増え、冗長になる点には注意が必要です。
選択基準は明確です。状態が単純で、更新パターンが1〜2種類程度ならuseState。状態ロジックが複雑化したり、複数の関連する状態があり更新パターンが3つ以上ある場合はuseReducerを検討しましょう。
グローバル状態:ライブラリの使い分け
グローバル状態の管理では、プロジェクトの規模とチームの特性に応じてライブラリを選択します。
Context APIは、React標準で学習コストが低く、バンドルサイズの増加もゼロです。テーマ設定やユーザー認証情報など、小規模な共有状態に適しています。
Zustand / Jotaiは、中規模アプリケーション向けの軽量ライブラリです。Zustandは単一ストア(1つの状態オブジェクト)を中心としたシンプルで直感的な設計です。Jotaiはアトム(独立した最小単位の状態)を組み合わせる設計で、細粒度の更新制御が可能です。両者の選択は設計思想の好みに依存します。 ※ 類似ライブラリのRecoilは2024年で開発が止まっており、新規採用は推奨されません。
Redux Toolkitは、大規模で複雑なアプリケーション向けです。充実したDevToolsとエコシステムがあり、チーム開発での標準化に向いています。学習コストは高いですが、スケーラビリティと保守性が得られます。
具体的なコード例で、Context APIとZustandの違いを見てみましょう。同じカウンター機能を実装する場合の比較です。
// Context API(React 19形式):小規模アプリや更新頻度が低い状態に最適 import { createContext, use, useState } from 'react'; const CounterContext = createContext(null); function CounterProvider({ children }) { const [count, setCount] = useState(0); return ( <CounterContext value={{ count, setCount }}> {children} </CounterContext> ); } // 使用側はuseでContextを取得 function Counter() { const { count, setCount } = use(CounterContext); return <button onClick={() => setCount(count + 1)}>Count: {count}</button>; }
// Zustand:中規模以上や複雑な状態管理に最適 import { create } from 'zustand'; const useCounterStore = create((set) => ({ count: 0, increment: () => set((state) => ({ count: state.count + 1 })) })); // 使用側は直接ストアを使用 function Counter() { const { count, increment } = useCounterStore(); return <button onClick={increment}>Count: {count}</button>; }
Context APIは小規模アプリやテーマ・ロケールなど更新頻度が低い状態に適しています。ただし、Contextの値が更新されると、それを参照する全ての子コンポーネントが再レンダリングされるため、高頻度で更新される状態には向きません。一方、Zustandは中規模以上のアプリや、複雑な状態管理が必要な場合にスケールしやすい設計になっています。

以下の比較表で、各ライブラリの特性を整理します。
| ライブラリ | 学習コスト | バンドルサイズ | 適用規模 | 特徴 |
|---|---|---|---|---|
| Context API | 低 | 0KB | 小 | React標準、追加依存なし |
| Zustand | 低 | 1.3KB | 中 | シンプルなAPI、直感的 |
| Jotai | 中 | 11.3KB | 中〜大 | アトミック設計、細粒度制御 |
| Redux Toolkit | 高 | 41.3KB | 大 | 充実したツール、標準化 |
※バンドルサイズはhttps://bundlephobia.com/での計測値(Minified)
サーバー状態:TanStack Query vs SWR
サーバーから取得するデータは、クライアント状態とは異なる特性を持ちます。キャッシング、自動再検証、バックグラウンド更新といった要件に対応するため、専用のライブラリを使用することが推奨されます。
従来のuseEffect + useStateアプローチでは、ローディング・エラーハンドリング・キャッシュ管理を手動実装する必要があり煩雑でした。専用ライブラリでこの複雑さを軽減できます。
TanStack Query(旧React Query)は、クエリの無効化制御、楽観的更新、無限スクロール対応、DevToolsなど高度なキャッシュ戦略に対応し、複雑な要件を持つアプリケーションに向いています。SWRはVercel社がメンテナンスしており、設定項目が少なくシンプルなAPIで基本的なキャッシングと再検証を簡潔に実現できます。Next.jsとの親和性も高いです。
// ✅ TanStack Query:宣言的なデータフェッチ import { useQuery } from '@tanstack/react-query'; function UserList() { const { data: users, isLoading, error } = useQuery({ queryKey: ['users'], queryFn: () => fetch('/api/users').then(res => res.json()) }); if (isLoading) return <div>Loading...</div>; if (error) return <div>Error: {error.message}</div>; return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>; }
選択基準はプロジェクトの複雑さに依存します。シンプルなデータフェッチならSWR、高度な制御が必要ならTanStack Queryが適しています。
Container/Presentationalパターンの進化
状態管理の手法を理解する上で、パターンの歴史的な変遷を知ることも重要です。ここでは、Container/Presentationalパターンから現代的なCustom Hooksへの進化について解説します。
従来のパターンとその課題
Container/Presentationalパターンは、かつて広く推奨されていた設計パターンです。ロジックを持つContainerコンポーネントと、表示のみを担当するPresentationalコンポーネントを分離することで、関心の分離を実現していました。Presentationalコンポーネントは純粋な表示ロジックのみを持つため再利用性が高く、Propsを通じた明確なインターフェースによりテストも容易でした。
また、Hooks登場以前は、状態の管理や表示後の副作用を実現するために、クラス形式のコンポーネント実装を利用していました。クラス内のメソッドとしてビジネスロジックを実行することが多く、クラス内でビジネスロジックとUIが密結合することを助長していました。
// ❌ 従来のContainer/Presentationalパターン class UserListContainer extends React.Component { state = { users: [], loading: true }; componentDidMount() { // データフェッチ処理 } render() { return <UserListPresentation users={this.state.users} loading={this.state.loading} />; } } function UserListPresentation({ users, loading }) { if (loading) return <div>Loading...</div>; return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>; }
このパターンの課題は、ContainerとPresentationalそれぞれのファイル管理が煩雑になる点、同じロジックを別のコンポーネントで使いたい場合にContainerごと複製する必要があり再利用が困難な点、そしてコンポーネント数が増えるにつれPropsリレーが複雑化する点でした。
Custom Hooksによる解決
2019年のReact Hooks登場により、状況は大きく変わりました。Custom Hooksを使うことで、ロジックを独立した関数として切り出し、どのコンポーネントからでも再利用できるようになりました。
// ✅ Custom Hooks + Presentational Component function useUsers() { const [users, setUsers] = useState([]); const [loading, setLoading] = useState(true); useEffect(() => { // データフェッチ処理 }, []); return { users, loading }; } function UserList() { const { users, loading } = useUsers(); if (loading) return <div>Loading...</div>; return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>; }
この現代的なアプローチでは、ロジックが独立しているためテストが容易で、複数のコンポーネントで再利用でき、ファイル分割も不要でコードがシンプルになります。

現代的なベストプラクティス
現在では、Custom HooksとPresentationalコンポーネントを組み合わせたアプローチが主流です。関心の分離という基本原則は維持しつつ、実装はよりシンプルで柔軟になりました。パターンに固執するのではなく、プロジェクトの特性と要件に応じて、最適な設計を選択することが重要です。
まとめ
状態管理の実践的な使い分けについて学んできました。重要なポイントは、状態の種類を見極め、適切なツールを選ぶことです。
ローカル状態にはuseStateまたはuseReducer、グローバル状態にはContext API、Zustand、Jotai、Redux Toolkitをプロジェクト規模に応じて選択、そしてサーバー状態にはTanStack QueryまたはSWRを使用します。また、Container/Presentationalパターンから学べるように、パターンへの固執ではなく、目的志向の設計が大切です。
状態管理の設計が整理されたことで、次の課題が見えてきます。それは「設計した状態管理が正しく動作することをどう保証するか」です。次回は、Testing Trophy思想とStorybookを活用したテスト駆動なコンポーネント開発について解説します。適切なテスト戦略は、今回学んだ状態管理の品質を保ち、リファクタリングを安全に行うための基盤となります。


