状態管理
フロントエンドアプリケーションのデータの流れと状態の変更を一元的に管理する手法
状態管理とは
状態管理 (State Management) は、フロントエンドアプリケーションの UI の状態 (ユーザー入力、API レスポンス、表示/非表示) をどこに置き、誰が書き換えられるかを決める手法である。React のようなコンポーネント指向の作りでは、画面は状態から描かれる。同じ値を複数の場所が別々に持つと、片方だけ更新された瞬間に画面の中で表示が食い違う。状態管理の実質は、値ごとに正となる置き場所を 1 つに決め、更新の経路をそこへ集めることにある。
状態の種類
最初に効くのは、その値の正がどこにあるかで状態を分けることである。サーバー状態は手元にあるのが複製で、正はサーバーにある。だから時間が経てば古くなり、他の利用者の操作で変わり、必要なら取り直さなければならない。クライアント状態は正が手元にあるので、古くなるという概念がそもそも存在しない。この 2 つを同じ道具で管理すると、本来は道具が面倒を見るはずの再取得のタイミングやキャッシュの破棄を、すべて自分で書くことになる。
| 種類 | 例 | 管理方法 |
|---|---|---|
| ローカル状態 | フォーム入力、モーダルの開閉 | useState |
| グローバル状態 | ログインユーザー、テーマ | Context, Zustand |
| サーバー状態 | API レスポンス、キャッシュ | TanStack Query |
| URL 状態 | ページ、フィルター、ソート | URL パラメータ |
見落とされやすいのが URL 状態である。ページ番号や絞り込み条件のように、リンクを共有したとき、ブラウザの戻る/進むを押したとき、再読込したときに保たれているべき値は、コンポーネントではなく URL に置く。SPA では画面の切り替えを自前のルーターが担うため、URL を更新しないまま表示だけ変えることが技術的には可能で、その結果として共有されたリンクが初期状態で開く不整合が生まれる。
React の状態管理ツール
道具の選択は、上の 4 分類とほぼ 1 対 1 で決まる。以下は 2026 年 8 月時点の主要な選択肢である。
| ツール | 特徴 | 適するケース |
|---|---|---|
useState | React 組み込み | ローカル状態 |
useContext | React 組み込み、プロップドリリング解消 | テーマ、認証 |
| Zustand | 軽量、シンプル API | 中規模アプリ |
| Jotai | アトミック、ボトムアップ | 細粒度の状態管理 |
| Redux Toolkit | 大規模、DevTools | 大規模アプリ |
| TanStack Query | サーバー状態専用 | API キャッシュ |
メジャーバージョンは 2026 年 8 月時点で Zustand が v5 系 (v5.0.0 は 2024 年 10 月公開)、TanStack Query が v5 系 (v5.0.0 は 2023 年 10 月公開)、Redux Toolkit が v2 系 (v2.0.0 は 2023 年 12 月公開)、Jotai が v2 系 (v2.0.0 は 2023 年 1 月公開) である。いずれも数年にわたって同じメジャー系列が続いており、新規に採用しても直後に大きな移行が待っている状況ではない。
useState (ローカル状態)
1 つのコンポーネントの中で完結する状態は useState で持つ。useState は Hooks の 1 つで、コンポーネントが再描画されても値が保持される。
function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}
更新に setCount(count + 1) ではなく更新関数形の c => c + 1 を渡しているのは、直前の値を引数として受け取れるからである。count を直接読む書き方では、1 つのイベントハンドラーの中で 2 回呼んでも、どちらも同じ描画時点の値を見るため、増えるのは 1 だけになる。更新関数形なら 2 回分が順に適用される。
Zustand (グローバル状態)
複数のコンポーネントが同じ値を読む状態は、コンポーネントの木の外に置いて、必要な場所から直接購読させる。
import { create } from 'zustand';
const useStore = create<{ user: User | null; login: (u: User) => void }>((set) => ({
user: null,
login: (user) => set({ user }),
}));
// コンポーネントで使用
function Header() {
const user = useStore(state => state.user);
return <div>{user?.name ?? 'Guest'}</div>;
}
要点は state => state.user のセレクタである。購読する範囲をここで絞ると、ストアの別のキーが書き換わっても、この値が変わらない限りコンポーネントは再描画されない。useStore() のようにストア全体を受け取る書き方にすると、毎回新しいオブジェクトを見ることになり、この絞り込みが効かなくなる。
TanStack Query (サーバー状態)
サーバー状態は、取得と再取得の面倒を専用の道具に任せる。
function UserList() {
const { data, isLoading } = useQuery({
queryKey: ['users'],
queryFn: () => fetch('/api/users').then(r => r.json()),
staleTime: 5 * 60 * 1000, // 5 分間は再取得しない
});
if (isLoading) return <div>Loading...</div>;
return <ul>{data.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}
staleTime は取得したデータを新鮮とみなす時間で、この間は再マウントやウィンドウのフォーカスを契機とした再取得が走らない。既定値は 0 で、キャッシュにあるデータも常に古いものとして扱われる。混同しやすいのが gcTime で、こちらは画面から消えて誰も購読しなくなったデータをキャッシュに残しておく時間 (既定 5 分) を指す。前者は再取得の頻度を、後者は再訪時に古い値をいったん表示できるかを決める別の設定である。
キャッシュ、再取得、楽観的更新をこの層に寄せられるため、API レスポンスをグローバルストアへ写して自分でキャッシュの寿命を管理する必要は基本的になくなる。ストアへ写すのは、取得した値をユーザーが編集して保存前に保持する場合のように、正が手元へ移るときに限られる。
選択基準
置き場所は、必要になってから外側へ広げる。この順で考えると迷いにくい。
ローカル状態 → useState
↓ 複数コンポーネントで共有
グローバル状態 → Zustand (シンプル) or Redux (大規模)
↓ API レスポンスのキャッシュ
サーバー状態 → TanStack Query
逆方向の失敗が両端にある。最初からグローバルストアを用意してすべての状態を集めると、どのコンポーネントがどの値に依存しているかが追えなくなり、更新の影響範囲も広がる。反対に、共有が必要な状態をプロップスで何段も渡し続けると、途中のコンポーネントが自分では使わない値を運ぶだけの中継役になる。
もう 1 つの定番の落とし穴が、他の状態から計算できる値を別の状態として持つことである。商品リストと合計金額を別々の状態で保持すると、片方だけ更新された瞬間に画面の中で数字が矛盾する。派生値は状態にせず、描画のたびに計算する。
より深く学ぶには関連書籍が役立つ。
この記事は役に立ちましたか?
関連用語
React
コンポーネントベースの UI ライブラリで、宣言的な記法と仮想 DOM の差分計算で UI の更新を扱う
Hooks
React の関数コンポーネントに状態管理や副作用などの機能を追加する仕組み
SPA
ページ遷移なしに動的にコンテンツを更新する Web アプリケーションのアーキテクチャ
Storybook
UI コンポーネントをアプリ本体から切り離した環境で描画し、状態ごとの見た目と操作を確認・テスト・文書化する開発環境
Vue.js
学習しやすさと柔軟さを兼ね備えたフロントエンドの JavaScript フレームワーク
ステートマシン
有限個の状態と遷移で振る舞いを定義する計算モデルで、ワークフローや UI の状態管理に使う