本記事について
当サイトを閲覧いただきありがとうございます。 本記事はシリーズ『生成AI時代のアーキテクチャ超入門』の「フロントエンドアーキテクチャ」カテゴリ第3弾として、状態管理について解説する記事です。
モダンフロントエンドで最も設計が難しい領域。本記事では状態の5種類(UI・ドメイン・サーバ・URL・永続)の分類、Zustand・TanStack Query・React Hook Form + Zodといった現代定番スタック、設計原則、そして「同じ事実を2箇所に書かない」という鉄則を解説します。
このカテゴリの他の記事
「この値、どこで管理してるの?」
アプリの規模が大きくなるほど、設計の良し悪しが露骨にコードの品質に現れます。「この値どこで管理してるの?」「更新したのに反映されない」といった混乱は、状態管理設計の失敗がほぼ原因です。一度こじれると、後から直すのは新規で書き直すより大変な領域です。
状態は種類ごとに最適な管理方法が違います。一括りに扱うと必ず破綻します。
状態の種類
まず重要なのが、「状態」と一口に言っても性質の全く違う5種類が存在するという認識です。これらを同じ仕組み(Redux等)で管理しようとすると必ず破綻します。
flowchart TB
STATE([状態])
UI[UI状態<br/>モーダル/入力/Loading]
DOM[ドメイン状態<br/>カート/お気に入り]
SRV[サーバ状態<br/>API取得データ]
URL[URL状態<br/>クエリ/ページネーション]
PERS[永続状態<br/>ログイン/テーマ]
UI_TOOL[useState<br/>useReducer]
DOM_TOOL[Zustand<br/>Jotai]
SRV_TOOL[TanStack Query<br/>SWR]
URL_TOOL[Next.js Router<br/>nuqs]
PERS_TOOL[Cookie<br/>localStorage]
STATE --> UI --> UI_TOOL
STATE --> DOM --> DOM_TOOL
STATE --> SRV --> SRV_TOOL
STATE --> URL --> URL_TOOL
STATE --> PERS --> PERS_TOOL
classDef root fill:#fef3c7,stroke:#d97706;
classDef kind fill:#dbeafe,stroke:#2563eb;
classDef tool fill:#dcfce7,stroke:#16a34a;
class STATE root;
class UI,DOM,SRV,URL,PERS kind;
class UI_TOOL,DOM_TOOL,SRV_TOOL,URL_TOOL,PERS_TOOL tool;
| 種類 | 例 |
|---|---|
| UI状態 | モーダル開閉・入力値・ローディング表示 |
| ドメイン状態 | カート内容・お気に入りリスト |
| サーバ状態 | APIから取得したユーザー一覧・商品詳細 |
| URL状態 | クエリパラメータ・ページネーション・検索条件 |
| 永続状態 | ログイン情報・テーマ設定・下書き |
特に「サーバ状態とUI状態を混ぜる」のが典型的な失敗で、この2つはキャッシュが必要か・いつ古くなるかといった根本的な性質が異なります。状態設計の第一歩は、扱うデータがどの種類に属するかを見極めることです。
現代の常識は「サーバ状態はTanStack Query、UI状態はuseState / Zustand」と分けて管理することです。
ローカル状態とLift State Up
最もシンプルで最も多く使うのがローカル状態です。1つのコンポーネント内でしか使わない値なら、useState で持つのが最もシンプルで問題が起きません。
const [count, setCount] = useState(0)
const [isOpen, setIsOpen] = useState(false)
重要な原則は「複数コンポーネントで使いたくなってから上に持ち上げる」ことを考えれば十分、ということです。最初からグローバル状態に入れる必要はありません。「必要になる前に抽象化するのは設計の失敗」につながります。
状態を複数のコンポーネントで共有したくなったら、まずは共通の親コンポーネントに持ち上げるのが基本です。これを Lift State Up と呼びます。
[Parent] ← useState でここに持つ
├─ Child1 ← propsで受け取る
└─ Child2 ← propsで受け取る(setterも渡す)
この方法は React 公式の鉄板パターンで、シンプルで明示的な利点があります。ただし階層が深くなってくると、中間のコンポーネントを props がバケツリレーのように通過する「Prop Drilling」(propの垂れ流し)が起きます。4〜5層以上になったら、Context または外部Storeの導入を検討するタイミングです。
シンプルな値は useState で十分。早すぎる抽象化こそ最大の敵です。
グローバル状態の選択肢
アプリ全体で共有する状態(ログインユーザー情報・テーマ・サイドバーの開閉等)には、グローバルストアを使います。選択肢は複数あり、チームの好みとアプリ規模で選びます。
| ライブラリ | 特徴 |
|---|---|
| Redux / Redux Toolkit | 古参・エコシステム巨大・ボイラープレート多 |
| Zustand | 軽量・フック主導・書き心地がシンプル |
| Jotai | アトム指向・React並行モード対応 |
| Recoil | Facebook発だがメンテ停滞中・選ばない方が無難 |
| Valtio | Proxyベース・書き味が自然 |
| MobX | Observable主導・Vue的な感覚 |
新規プロジェクトなら Zustand または Jotai が本命です。Redux は複雑な大規模SPAや、Redux DevTools のデバッグ力を活かしたいケースに限定。Reduxの「アクション → リデューサー → ストア」の図式は概念的には美しいですが、「記述量の多さ」が嫌われる傾向にあります。
新規採用は Zustand / Jotai。Reduxを選ぶなら明確な理由を持って選びます。
React Contextの注意
React標準の Context API は、グローバルストアの軽量版として使えます。ただし重大な制約があり、Contextの値が1つでも変わると、購読している全コンポーネントが再レンダリングされるという仕様があります。
❌ ContextにフォームState全体を突っ込む
→ 毎キー入力で全購読コンポーネントが再描画(パフォーマンス壊滅)
✅ Contextは変化頻度の低い情報に限定
→ テーマ / 認証情報 / i18n(言語設定)等
このため、「頻繁に変わる値をContextに入れるのはアンチパターン」です。フォームの入力値やカウンタなどを Context に乗せてはいけません。一方、テーマ・認証状態・i18n のような「稀にしか変わらない値」には最適です。
サーバ状態は特別
APIから取得したデータ(サーバ状態)は、UI状態とは根本的に性質が違うため、同じ仕組みで扱ってはいけません。これは現代フロントエンド設計における最重要の原則です。
| 特徴 | UI状態 | サーバ状態 |
|---|---|---|
| 真実の出所 | ローカル | サーバ |
| 古くなるか | ならない | なる(再取得が必要) |
| 同期の必要 | なし | あり(キャッシュ制御) |
| 複数コンポーネントで必要 | 時々 | 頻繁に |
これを理解していないと、Reduxに取得したユーザーリストを突っ込んで「いつリフレッシュするか」を手書きで実装し、キャッシュバグと古いデータ表示に悩まされる、という古典的な苦行に陥ります。サーバ状態には専用ライブラリを使うのが本命です。
サーバ状態のライブラリ
サーバ状態を扱う専用ライブラリは、キャッシュ管理・再取得・楽観更新といった面倒な処理を全て肩代わりしてくれます。現代のデファクトは TanStack Query(旧React Query)です。
| ライブラリ | 特徴 |
|---|---|
| TanStack Query(React Query) | デファクト。キャッシュ・再取得・楽観更新の全部入り |
| SWR | Vercel製。より軽量・シンプル |
| Apollo Client | GraphQL専用 |
| RTK Query | Redux Toolkit統合版 |
const { data, isLoading, error } = useQuery({
queryKey: ['users', id],
queryFn: () => fetchUser(id),
staleTime: 60_000, // 60秒間は再取得しない
})
queryKeyでキャッシュを管理(同じキーなら同じキャッシュを共有)staleTimeで再取得の頻度を制御invalidateQueriesで Mutation 後にキャッシュを無効化して自動再取得
この3つの仕組みだけで、大半の状態管理問題が解決します。
TanStack Query が既定の第一選択です。全部入りで学習コストも妥当です。
URL状態
ページネーション・検索条件・タブ選択のような情報は、URLに持たせるのが現代的なベストプラクティスです。Reactのステートに持つとブラウザバックやシェア時に情報が失われます。
❌ setState({ page: 2, search: "foo" })
→ ブックマーク不可・リロードで消える
✅ router.push('?page=2&search=foo')
→ ブックマーク可・シェア可・バック対応
URL状態のメリットは以下の通りです。
- ブックマーク可能(その状態を保存できる)
- ブラウザバック/フォワードに対応(履歴が自然に機能)
- シェア可能(URLを送るだけで同じ画面を再現)
- サーバが状態を知れる(SSR時にデータを返せる)
「画面遷移で消えても困らない値以外は、URLに入れる」が鉄則です。
フォーム状態
ユーザー入力を扱うフォームは、専用ライブラリを使うと実装が格段に楽になります。入力数が10個を超えるフォームで useState を使い続けると、管理が破綻します。
| ライブラリ | 特徴 |
|---|---|
| React Hook Form | 非制御・高速・デファクト |
| Formik | 制御式・旧来派・やや重い |
| TanStack Form | 新興・型システムが強力 |
| Zod(バリデーション) | スキーマ駆動のバリデーション決定版 |
「非制御(uncontrolled)方式」は、入力のたびにReactの状態を更新しない設計で、大きなフォームでも再レンダリングがほぼ発生しないため高速です。現代は React Hook Form + Zod がフォーム実装の事実上の標準です。
制御式は10個程度の小さなフォームまで。それを超えたら React Hook Form 一択 です。
永続化
アプリを閉じても残したい情報は、ブラウザの永続ストレージに保存します。何を何に入れるかには明確な使い分けがあり、セキュリティに直結します。
| 対象 | 保存先 | 理由 |
|---|---|---|
| 認証情報 | httpOnly Cookie(推奨) | XSS(Cross-Site Scripting、スクリプト注入攻撃)で盗めない |
| テーマ設定 | localStorage | 機密性低い設定値 |
| 下書き保存 | localStorage / indexedDB | サイズによる |
| セッションキャッシュ | sessionStorage | タブを閉じると消える |
| 大量データ | indexedDB | MB単位の格納も可 |
JWT(署名付き認証トークン)やセッションIDを localStorage に入れるのはXSS脆弱性の王道パターン。JavaScriptから読めるストレージは攻撃者からも読めるため、認証情報は必ず httpOnly Cookie に入れます。
認証情報はhttpOnly Cookie。localStorageに入れるのは地雷です。
「同じ事実を2箇所に書いた日」(業界事例)
フロント開発では、「items 配列と別に itemCount という数値ステートを作ってしまい、削除処理でどちらか片方だけ更新するバグに何度も遭遇した」という話をよく聞きます。items.length をその場で計算するだけで避けられるのに、ついつい「カウントは事前に持っておいた方が速そう」「他の場所でも使うから」と別ステートに切り出してしまう。この種の失敗は新人からベテランまで経験する、状態設計の王道の地雷です。
React学びたての頃に似たバグを起こし、レビューで「items.lengthでいいよね」と指摘されて頭を抱えた、という体験談もよく聞きます。教訓はシンプルで、「同じ事実を2箇所に書くと必ず片方が嘘になる」ということ。
派生状態は計算で導出する、URLで表せるものはURLに入れる、サーバ状態はサーバに事実を預ける。「状態を最小限に保つ」という原則は、抽象的な美学ではなく、過去のバグから学んだ具体的な防御策です。
状態管理の鉄則は「単一の真実の出所」これ一つでバグが激減します。
状態設計の原則
5種類の状態をうまく管理するための設計原則をまとめます。どれも大切ですが、特に「単一の真実の出所」と「派生状態は計算で導出」は、どの規模のアプリでも守るべき鉄則です。
- 単一の真実の出所(Single Source of Truth):同じ情報を2箇所に持たない。必ず片方が主でもう一方は従にする
- 派生状態は計算で導出:
items.length === 0を別のステートで持たず、その場で計算する - サーバとクライアント状態を混ぜない:TanStack Query と Zustand を使い分ける
- 状態を最小限に保つ:計算できるものは保持しない(バグの温床になる)
- URLで表現できるものはURLに:ページネーションや検索条件
規模別の推奨スタック
「全部Reduxに入れる」が破綻の元。プロジェクト規模と状態種別で複数ライブラリを組み合わせるのが現代の鉄板です。
| プロジェクト規模 | UI状態 | サーバ状態 | フォーム | 永続化 |
|---|---|---|---|---|
| 個人・MVP(〜1000行) | useState | fetch直叩き + useState | useState | localStorage |
| 初期スタートアップ(〜5000行) | useState + Context | TanStack Query | React Hook Form + Zod | localStorage |
| 中規模SaaS(〜30,000行) | Zustand | TanStack Query | RHF + Zod | localStorage + httpOnly Cookie |
| 大規模SPA(30,000行〜) | Zustand + Context | TanStack Query | RHF + Zod | Cookie中心 |
| Next.js App Router | Zustand(Client)+ RSC(Server) | Server Componentsで取得 | Server Actions + Zod | httpOnly Cookie |
「Reduxを新規採用する意味は2026年時点ではほぼない」が業界の共通認識です。Redux Toolkitを残しているのは、Redux DevToolsの時間旅行デバッグを活用する大規模プロジェクトか、既存Redux資産を抱えるチームだけです。Jotai / Valtio / Signalも選択肢ですが、情報量・採用事例でZustandが頭ひとつ抜けているのが現状です。
新規採用は Zustand + TanStack Query + RHF + Zod。Reduxを選ぶ理由は狭いです。
状態管理の鬼門・禁じ手
状態設計で事故る典型を整理します。どれも無限ループ・二重更新・XSS漏洩の直接原因になります。
| 禁じ手 | なぜダメか |
|---|---|
| JWTをlocalStorageに保存 | XSS一発で全員分漏洩の地雷。httpOnly Cookie必須 |
| サーバ状態をReduxに入れてキャッシュロジック自作 | 古い設計。TanStack Query / SWR で自動化できる |
| 同じ事実を2箇所に持つ(items配列とitemCount別ステート) | 片方が必ず嘘になる。派生状態は計算で導出 |
| React Contextに頻繁に変わる値を入れる | 購読コンポーネント全てが再描画。パフォーマンス壊滅 |
| 早すぎるグローバル化(useStateで足りるのにZustand) | オーバーヘッドだけで恩恵なし。必要になってから |
| フォームの入力値をContextで管理 | キー入力ごとに全再描画。React Hook Formで解決 |
| ブラウザバック / リロードで消える値をステートに持つ | URLに入れればシェア・ブックマーク可能 |
| ページネーション・検索条件をuseState | URLクエリに入れるのが鉄則。リロードで消える設計はUX劣悪 |
| useEffect でデータ取得を手書き | ローディング・エラー・競合状態の地雷。TanStack Query で解決 |
| Refresh Tokenをlocalstorage | XSSで全端末セッション奪取可能。httpOnly Cookie必須 |
「useStateからの段階的な拡張」が健全なパスです。最初からReduxやJotaiを入れるのは典型的な過剰設計で、Context → Zustand → Redux Toolkit の順で必要に応じて昇格させます。
早すぎる抽象化は最大の敵。状態管理は必要になってから拡張します。
AI時代の視点
AI駆動開発が前提になると、状態管理は「AIがライブラリのパターンを知っているか」が生成精度を決めます。Zustand・TanStack Query・React Hook Form + Zodの組み合わせは「学習データが豊富」で、AIが極めて高精度なコードを生成できます。逆にReduxのボイラープレートや独自ストアは、AIが古いパターンを混ぜたり、型が合わないコードを生成したりしがちです。
| AI時代に有利 | AI時代に不利 |
|---|---|
| Zustand(フック主導・シンプル) | Reduxの古典的3ファイル分割 |
| TanStack Query + React Hook Form + Zod | 自作キャッシュ・独自バリデーション |
| Server Components + Server Actions | クライアントで全部フェッチ |
| URL状態(searchParams活用) | ステートに詰め込む設計 |
AIは「ユーザー一覧画面を作って」と指示すると、TanStack Queryで取得・ローディング・エラーまで一気に書いてくれます。この精度は主要ライブラリの収束によって急速に向上しており、主流スタックに寄せるほどAIの生産性ブーストが効きます。Zod等のスキーマ駆動は、APIの型定義がフロント・バック・AIの共通言語として機能するため、AI時代に特に価値が高まる領域です。
よくある勘違い
- Reduxに全部入れる → サーバ状態もUI状態もReduxで管理し、キャッシュロジックを自作して破綻。「種類で道具を分ける」のが鉄則
- ContextはReduxの代替 → 頻繁に変わる値を入れるとパフォーマンス壊滅。Contextは「稀にしか変わらない値」専用
- JWTはlocalStorageでOK → XSSで一撃で盗まれる地雷。認証情報はhttpOnly Cookieが鉄則
- グローバルに入れれば楽 → 早すぎる抽象化こそ最大の敵。「useStateから始めて、必要になったら上に持ち上げる」のが正解
決めるべきこと — あなたのプロジェクトでの答えは?
以下の項目について、あなたのプロジェクトの答えを1〜2文で言語化してみてください。曖昧なまま着手すると、必ず後から「なぜそう決めたんだっけ」が問われます。
- グローバル状態ライブラリの採否と選定(Zustand / Jotai / Redux)
- サーバ状態ライブラリの選定(TanStack Query / SWR)
- フォームライブラリとバリデーション(React Hook Form + Zod)
- 永続化戦略(Cookie / localStorage の使い分け)
- URL状態の利用範囲
- 型定義の共有方法(Zod / tRPC / OpenAPI)
最終的な判断の仕方
状態管理の核心は「状態の種類を見極めて、種類ごとに最適な道具を使う」ことです。UI状態・ドメイン状態・サーバ状態・URL状態・永続状態。性質の違う5種類を1つの道具(特にRedux)で扱おうとするのが全ての破綻の始まり。
サーバ状態はキャッシュと再取得が本質だからTanStack Queryに、URL状態はブックマーク性のためにsearchParamsに、フォームは再レンダリング削減のためにReact Hook Formに、UI状態はuseStateかZustandに。「分けて管理するだけで、状態起因のバグが激減」します。最初から分けて設計する、これが鉄則です。
決定的な軸は「主流スタック + スキーマ駆動」に寄せることです。Zustand + TanStack Query + React Hook Form + Zodという組み合わせは学習データが圧倒的に多く、AIは「ユーザー一覧画面を作って」と指示するだけで取得・ローディング・エラー・キャッシュ無効化まで一気に書いてくれます。
Zodスキーマはフロント・バック・AIの共通言語として機能し、型安全とバリデーションが同時に手に入ります。逆にRedux古典3ファイル分割や独自ストアは、AIが古いパターンを混ぜて精度が落ちます。早すぎる抽象化を避け、シンプルから始めて必要に応じてZustandやContextを足していくのが健全なパスです。
選定の優先順位をまとめると次の通りです。
- 状態の種類で道具を分ける(UI/ドメイン/サーバ/URL/永続)
- サーバ状態は専用ライブラリ(TanStack Query一択)
- 主流スタックに寄せる(Zustand + TanStack Query + RHF + Zod)
- スキーマ駆動(Zodで型を共通言語に)
まとめ
本記事は状態管理について、5種類の状態分類・主流スタック・URL状態・永続化まで含めて解説しました。如何だったでしょうか。
状態を最小限に保ち、種類で道具を分け、主流スタックとスキーマ駆動に寄せる。これが2026年の状態管理の現実解です。
次回はフレームワーク詳細(React/Vue/Svelte/Next.js/Astro)について解説します。
シリーズ目次に戻る → 『生成AI時代のアーキテクチャ超入門』の歩き方
それでは次の記事も閲覧いただけると幸いです。
📚 シリーズ:生成AI時代のアーキテクチャ超入門(33/89)