7.2 状態管理 — Context / Zustand / Redux Toolkit
React の useState はコンポーネントに閉じた状態管理の仕組みです。props で親から子に渡すのが単方向データフロー(3.5 Props と単方向データフロー でやりましたね)の原則ですが、ツリーが深くなると中間コンポーネントを状態が ただ素通りするだけ の「prop drilling」が起きます。これを解決するためのツールがいくつかあります。
案件で候補に挙がるのは主に 3 つです。
- Context + useReducer(React 公式、ゼロ依存)
- Zustand(軽量なフックベースのストア)
- Redux Toolkit(Redux の公式推奨パッケージ、大規模向け)
ほかに Jotai、Valtio、MobX なども現場では出会いますが、選定軸としては上 3 つを押さえておけば判断できます。
サーバー状態とクライアント状態は、別問題です
まず大前提として、これら 3 つはすべて「クライアント状態」のための道具です。クライアント状態というのは、UI の開閉、フォーム入力、ログイン中のユーザー情報など、ブラウザが自分で持てる状態 のことです。
一方、API から取ってきたデータ(ユーザー一覧、商品一覧、記事本文など)は「サーバー状態」と呼ばれ、性質がまるで異なります。キャッシュ、再取得、ローディング表示、失敗時のリトライといった固有の関心ごとがあり、これらを Zustand や Redux Toolkit で手書きするのはかなり負担の大きい領域です。
サーバー状態には、次章 7.3 データフェッチ で扱う TanStack Query や SWR を使います。この区別を最初にはっきりさせておくと、後の章の話がずっと楽になります。
図 7.2.1 クライアント状態とサーバー状態の「顔つきの違い」
図 7.2.2 状態の分類
Context + useReducer(公式が推奨する軽量な解)
React 公式は Scaling Up with Reducer and Context で、小中規模の状態共有には 外部ライブラリを入れず Context + useReducer で十分、と案内しています。
最小例
// react.dev/learn/scaling-up-with-reducer-and-context のパターンを最小化
import { createContext, useContext, useReducer, type ReactNode } from "react";
type Task = { id: number; text: string; done: boolean };
type Action =
| { type: "added"; id: number; text: string }
| { type: "toggled"; id: number };
const TasksContext = createContext<Task[] | null>(null);
const TasksDispatchContext = createContext<React.Dispatch<Action> | null>(null);
function reducer(tasks: Task[], action: Action): Task[] {
switch (action.type) {
case "added":
return [...tasks, { id: action.id, text: action.text, done: false }];
case "toggled":
return tasks.map((t) =>
t.id === action.id ? { ...t, done: !t.done } : t
);
}
}
export function TasksProvider({ children }: { children: ReactNode }) {
const [tasks, dispatch] = useReducer(reducer, []);
return (
<TasksContext value={tasks}>
<TasksDispatchContext value={dispatch}>{children}</TasksDispatchContext>
</TasksContext>
);
}
export function useTasks() {
const v = useContext(TasksContext);
if (!v) throw new Error("TasksProvider の外で useTasks を呼んでいます");
return v;
}得意な領域と、苦手な領域
得意なところ:
- ゼロ依存で動きます。
- 機能ごとに Provider を分けて、影響範囲を物理的に制限できます。
- Reducer が純粋関数なのでテストしやすいです。
苦手なところ:
- Context は「値が変わるとその Provider 配下を 全部 再レンダー対象にする」性質があります。細かいセレクタで再レンダーを最小化する仕組みは持っていません(4.4 Context を参照)。
- 状態を参照する箇所が多い、あるいは更新頻度が高いと、再レンダーの範囲が広がりやすいです。
小中規模の案件や、機能単位で閉じた状態(テーマ、認証情報、モーダル開閉など)にはこれで十分です。
Zustand
Zustand(ツースタンド、ドイツ語で「状態」を意味します)は、Poimandres (pmndrs) が公開している軽量な状態管理ライブラリです。「a small, fast and scalable bearbones state-management solution using simplified flux principles」と自称しています。
Context とは違い、Provider を使わずフック単体 でストアに接続するのが特徴です。
最小例
// github.com/pmndrs/zustand の README ベース
import { create } from "zustand";
type BearStore = {
bears: number;
increase: () => void;
};
export const useBearStore = create<BearStore>((set) => ({
bears: 0,
increase: () => set((state) => ({ bears: state.bears + 1 })),
}));function Counter() {
const bears = useBearStore((state) => state.bears);
const increase = useBearStore((state) => state.increase);
return <button onClick={increase}>{bears} 匹</button>;
}特徴
- Provider 不要:
createで作ったフックを、どこからでも直接呼べます。 - セレクタで再レンダー最適化:
useBearStore((s) => s.bears)のようにスライスして購読すると、bearsだけが変わったときに のみ 再レンダーされます。 - API 面が小さい: 自称「bearbones(骨組みだけ)」の通り、覚えることが少なく学習コストが低いです。
中規模の SPA で、「Context だと再レンダー範囲が気になる、でも Redux Toolkit ほどの機能は要らない」という中間層にきれいにフィットします。
Redux Toolkit
Redux Toolkit(RTK)は、Redux の公式チームが「Redux の標準的な書き方」としてまとめたパッケージです (redux-toolkit.js.org)。公式ドキュメントは、Redux が抱えていた 3 つの課題に対応するために作られたと明記しています — 「Redux ストアの設定が複雑すぎる」「必要なパッケージが多い」「ボイラープレートが多い」の 3 つです。
主要 API
configureStore()—createStoreをラップしたもの。Redux DevTools Extension 対応と redux-thunk がデフォルトで組み込まれています。createSlice()— リデューサー関数・スライス名・初期状態を受け取り、アクションクリエイタと型を自動生成します。createAsyncThunk()— 非同期処理を扱い、pending / fulfilled / rejected の 3 アクションを自動でディスパッチします。createEntityAdapter()— 正規化データ(id をキーにしたレコード群)を扱うためのユーティリティ。- RTK Query — データ取得・キャッシングを扱うアドオン(TanStack Query と同じ領域)。
最小例
// redux-toolkit.js.org/introduction/getting-started のパターンを最小化
import { configureStore, createSlice, type PayloadAction } from "@reduxjs/toolkit";
const counterSlice = createSlice({
name: "counter",
initialState: { value: 0 },
reducers: {
incremented: (state) => {
state.value += 1; // Immer により「ミューテート風」に書けるが、実際には不変更新になる
},
incrementedBy: (state, action: PayloadAction<number>) => {
state.value += action.payload;
},
},
});
export const { incremented, incrementedBy } = counterSlice.actions;
export const store = configureStore({
reducer: { counter: counterSlice.reducer },
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;import { Provider, useDispatch, useSelector } from "react-redux";
import { store, incremented, type RootState, type AppDispatch } from "./store";
function Counter() {
const value = useSelector((s: RootState) => s.counter.value);
const dispatch = useDispatch<AppDispatch>();
return <button onClick={() => dispatch(incremented())}>{value}</button>;
}
export function App() {
return (
<Provider store={store}>
<Counter />
</Provider>
);
}強みと負担
強み:
- Redux DevTools のタイムトラベル: アクションごとに state のスナップショットが残り、過去の状態に戻って挙動を再現できます。大規模で state の遷移が複雑なときに強い武器になります。
- ミドルウェア(thunk / saga など)のエコシステム: 副作用処理のパターンが確立しています。
- チームで標準化しやすい:
createSliceの規約に沿うことで、複数人でも似た形のコードになります。
負担:
- ストア・Provider・スライス・セレクタなど、覚えることが多いです。小規模案件にはオーバースペックになりがちです。
- 学習コストは 3 つの中で一番高いところです。
選び方の軸
まずは Mermaid 図でざっくりした判断フローを示します。
図 7.2.3 状態管理の選定フロー
上の図は目安です。実際の案件では「既にその案件で使われているもの」を優先します。既存が Redux Toolkit なのに新規機能だけ Zustand を入れる、といった混在は避けるべき判断です。
選定ウィザード(触って確認してみてください)
Mermaid 図と同じ判断を、質問に答えていく形でなぞれるようにしたものを用意しました。実際の案件をイメージしながら Yes / No を押してみると、自分の状況でどの選択肢が浮上するかが見えてきます。
質問に Yes / No で答えていくと、このあとの章で扱うどのライブラリを検討すべきかの出発点が表示されます。あくまで目安です。案件で既に採用されているものがあれば、そちらを優先してください。
繰り返しになりますが、これはあくまで「既存の選択がないときの出発点」です。既存コードで採用されているライブラリがあれば、そちらに合わせるのが第一選択肢になります。