フロントエンドアーキテクチャ

状態管理 ― useState/Zustand/TanStack Query ― 生成AI時代のアーキテクチャ超入門

状態管理 ― useState/Zustand/TanStack Query ― 生成AI時代のアーキテクチャ超入門

本記事について

当サイトを閲覧いただきありがとうございます。 本記事はシリーズ『生成AI時代のアーキテクチャ超入門』の「フロントエンドアーキテクチャ」カテゴリ第3弾として、状態管理について解説する記事です。

モダンフロントエンドで最も設計が難しい領域。本記事では状態の5種類(UI・ドメイン・サーバ・URL・永続)の分類、Zustand・TanStack Query・React Hook Form + Zodといった現代定番スタック、設計原則、そして「同じ事実を2箇所に書かない」という鉄則を解説します。

本記事のテーマについてさらに詳しく知りたい方は『アプリケーションアーキテクチャ設計パターン』も参考にしてみてください。

そもそも状態管理とは何か

状態管理とは、ざっくり言えば「画面に表示されるデータや操作の状態を、アプリのどこで持ってどう更新するかを決めること」です。

ホワイトボードの情報共有を想像してください。小さなチーム(小規模アプリ)なら1枚のホワイトボード(useState)で全員が情報を確認できます。しかし部門が増えると、各部門に専用ボード(Zustand)を置き、全社掲示板(サーバ状態=TanStack Query)から最新情報を取得する仕組みが必要です。どの情報をどこに書くかのルールがないと、古い情報を見て判断する人が出てきます。

なぜ状態管理の設計が重要なのか

もし状態管理を曖昧にしたらどうなるか。「この値どこで管理してるの?」「更新したのに反映されない」といった混乱は、状態管理設計の失敗がほぼ原因です。アプリの規模が大きくなるほど露骨にコード品質に現れ、一度こじれると新規で書き直すより大変な領域です。

状態は種類ごとに最適な管理方法が違います。一括りに扱うと必ず破綻します。

状態の種類

まず重要なのが、「状態」と一口に言っても性質の全く違う5種類が存在するという認識です。これらを同じ仕組み(Redux等)で管理しようとすると必ず破綻します。

フロントエンド状態の5分類 ホワイトボードの情報共有と同じ。種類ごとに最適な管理方法が違う 1 UI状態 モーダル開閉 入力値 ローディング表示 useState コンポーネント ローカルで十分 2 ドメイン状態 カート内容 お気に入りリスト 選択中の商品 Zustand アプリ全体で 共有が必要 3 サーバ状態 API取得データ ユーザー一覧 商品詳細 TanStack Query キャッシュ・再取得 鮮度管理が必要 4 URL状態 検索条件 ページネーション フィルタ・ソート URLSearchParams 共有・ブクマ 可能にする 5 永続状態 ログイン情報 テーマ設定 下書き Cookie / LS リロード後も 維持する 典型的な失敗: サーバ状態とUI状態を同じ仕組み(Redux等)で一括管理 → キャッシュ破綻・再取得漏れ サーバ状態は TanStack Query / SWR、UI状態は useState / Zustand に分けるのが現代の常識 状態を最小限に保ち、種類で道具を分ける。早すぎる抽象化こそ最大の敵
種類
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並行モード対応
RecoilFacebook発だがメンテ停滞中・選ばない方が無難
ValtioProxyベース・書き味が自然
MobXObservable主導・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)デファクト。キャッシュ・再取得・楽観更新の全部入り
SWRVercel製。より軽量・シンプル
Apollo ClientGraphQL専用
RTK QueryRedux 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タブを閉じると消える
大量データindexedDBMB単位の格納も可

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行)useStatefetch直叩き + useStateuseStatelocalStorage
初期スタートアップ(〜5000行)useState + ContextTanStack QueryReact Hook Form + ZodlocalStorage
中規模SaaS(〜30,000行)ZustandTanStack QueryRHF + ZodlocalStorage + httpOnly Cookie
大規模SPA(30,000行〜)Zustand + ContextTanStack QueryRHF + ZodCookie中心
Next.js App RouterZustand(Client)+ RSC(Server)Server Componentsで取得Server Actions + ZodhttpOnly 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に入れればシェア・ブックマーク可能
ページネーション・検索条件をuseStateURLクエリに入れるのが鉄則。リロードで消える設計はUX劣悪
useEffect でデータ取得を手書きローディング・エラー・競合状態の地雷。TanStack Query で解決
Refresh TokenをlocalstorageXSSで全端末セッション奪取可能。httpOnly Cookie必須
「Reduxに全部入れる」設計サーバ状態とUI状態を混ぜてキャッシュ自作で破綻。種類で道具を分ける
「ContextはReduxの代替」と誤解頻繁に変わる値で全購読コンポーネント再描画。パフォーマンス壊滅

「useStateからの段階的な拡張」が健全なパスです。最初からReduxやJotaiを入れるのは典型的な過剰設計で、Context → Zustand → Redux Toolkit の順で必要に応じて昇格させます。

早すぎる抽象化は最大の敵。状態管理は必要になってから拡張します。

AI判断軸

AI時代に有利AI時代に不利
Zustand(フック主導・シンプル)Reduxの古典的3ファイル分割
TanStack Query + React Hook Form + Zod自作キャッシュ・独自バリデーション
Server Components + Server Actionsクライアントで全部フェッチ
URL状態(searchParams活用)ステートに詰め込む設計
  1. 状態の種類で道具を分ける(UI/ドメイン/サーバ/URL/永続)
  2. サーバ状態は専用ライブラリ(TanStack Query一択)
  3. 主流スタックに寄せる(Zustand + TanStack Query + RHF + Zod)
  4. スキーマ駆動(Zodで型を共通言語に)

AIが状態管理で間違えやすいパターン

AIにReactの状態管理を書かせると、以下の問題が起きやすいです。

  • サーバ状態をuseStateで管理してキャッシュ・再取得・楽観的更新が抜ける(TanStack Queryを使うべき箇所)
  • グローバルstateに何でも入れて再レンダリングが多発する
  • URLに含めるべきフィルタ条件をステートに閉じ込めてブックマーク不可にする

Zodスキーマでデータの型を明示し、TanStack Queryでサーバ状態を分離する設計を先に決めておけば、AIはその制約の中で正確なコードを書きます。

Zodスキーマが型安全とAI精度の両方を支える

Zodで定義したスキーマは、フォームバリデーション・API レスポンスの型チェック・テストデータの生成に一元的に使えます。AIにとっても、Zodスキーマが存在すればデータの形状が明確なため、正しい型のコードを生成する精度が上がります。スキーマなしの動的データ構造では、AIが推測に頼って型エラーを含むコードを出しがちです。

決めるべきこと — 自分のプロジェクトでの答えは?

以下の項目について、自分のプロジェクトの答えを1〜2文で言語化してみてください。曖昧なまま着手すると、必ず後から「なぜそう決めたんだっけ」が問われます。

  • グローバル状態ライブラリの採否と選定(Zustand / Jotai / Redux)
  • サーバ状態ライブラリの選定(TanStack Query / SWR)
  • フォームライブラリとバリデーション(React Hook Form + Zod)
  • 永続化戦略(Cookie / localStorage の使い分け)
  • URL状態の利用範囲
  • 型定義の共有方法(Zod / tRPC / OpenAPI)

この記事に関連する記事

https://senkohome.com/arch-intro-frontend-bff/ https://senkohome.com/arch-intro-frontend-framework/ https://senkohome.com/arch-intro-frontend-overview/

まとめ

本記事は状態管理について、5種類の状態分類・主流スタック・URL状態・永続化まで含めて解説しました。如何だったでしょうか。

状態を最小限に保ち、種類で道具を分け、主流スタックとスキーマ駆動に寄せる。これが2026年の状態管理の現実解です。

次回はフレームワーク詳細(React/Vue/Svelte/Next.js/Astro)について解説します。

シリーズ目次に戻る → 『生成AI時代のアーキテクチャ超入門』の歩き方

本記事で扱った内容の詳細は Redux 公式ドキュメント も合わせて参考にしてください。

それでは次の記事も閲覧いただけると幸いです。