Reactの状態管理は選択肢が多すぎて迷いがちです。Zustand、Jotai、Redux Toolkit、TanStack Query——どれをどこに使えばいいのか。答えは「1つに統一する」ではなく「状態の性質ごとに使い分ける」です。
この記事では、状態を5つの層に分類し、各層に最適なライブラリを当てはめる実践的な設計パターンを解説します。Claude CodeのCLAUDE.mdにこの方針を書いておけば、コンポーネント生成のたびに正しいライブラリが自動で選ばれるようになります。
状態管理の5層モデル
Reactアプリケーションの状態は、性質ごとに5つの層に分類できます。それぞれに最適なツールが異なります。
| 層 | 内容 | 推奨ツール |
|---|---|---|
| Server State | APIから取得したデータ・キャッシュ・再検証 | TanStack Query v5 |
| Global State | 認証情報・テーマ・通知(アプリ全体で共有) | Zustand v5 |
| Atomic State | フィルタ・選択状態(複数コンポーネントで細かく共有) | Jotai v2 |
| Local State | モーダル開閉・入力値(コンポーネント内で完結) | useState / useReducer |
| Form State | フォームバリデーション・送信状態 | React Hook Form + Zod |
判断フローチャート: APIから取得したデータ?→TanStack Query。アプリ全体で共有?→Zustand。複数コンポーネントで細かく共有?→Jotai。コンポーネント内で完結?→useState。フォーム?→React Hook Form。
よくあるアンチパターン:APIレスポンスを
useStateやZustandに入れる。サーバーから取得したデータはTanStack Queryに任せてください。キャッシュ・再検証・ローディング状態・エラーハンドリングが自動化されます。CLAUDE.mdに状態管理方針を記述する
CLAUDE.md(状態管理ルール)
## 状態管理
### 5層アーキテクチャ
- Server State: TanStack Query v5(APIデータはすべてここ。useStateに入れない)
- Global State: Zustand v5(認証・テーマ・通知)
- Atomic State: Jotai v2(フィルタ・選択状態・UIの細粒度共有)
- Local State: useState/useReducer(コンポーネント内完結)
- Form State: React Hook Form + Zod
### ディレクトリ構造
src/
stores/ # Zustand ストア(auth.ts, ui.ts等)
atoms/ # Jotai atoms(filters.ts, selection.ts等)
hooks/queries/ # TanStack Query カスタムフック
### ルール
- APIデータは必ずTanStack Queryで管理(useStateに入れない)
- Zustandストアは機能単位で分割(auth, ui, notifications)
- Zustandセレクタは個別フィールドで購読(オブジェクト全体を取らない)
- グローバル状態に入れる前に「本当に複数コンポーネントで必要か」確認
- TanStack Queryのキーは配列形式で階層的に(["todos", "list", { filter }])
- Recoilは使用しない(メンテナンスモード)
- Redux Toolkitは既存プロジェクトでのみ使用(新規はZustand)
Zustand v5 ── グローバル状態管理の実践
Zustand v5はバンドルサイズ約1.1KB(gzip)で、React 19に完全対応しています。Recoilがメンテナンスモードに入った現在、グローバル状態管理のデファクトです。
Zustandストア生成プロンプト
認証ストアをZustand v5で作成してください。 要件: - user, token, isAuthenticated を管理 - login/logoutアクション - persistミドルウェアでlocalStorageに永続化(user/tokenのみ) - immerミドルウェアでイミュータブル更新 - devtoolsミドルウェア(開発環境のみ) - セレクタのベストプラクティスに従う
stores/auth.ts(Claude Codeが生成)
import { create } from "zustand";
import { persist, devtools, immer } from "zustand/middleware";
import { createJSONStorage } from "zustand/middleware";
interface User {
id: string;
name: string;
email: string;
role: "admin" | "editor" | "viewer";
}
interface AuthStore {
user: User | null;
token: string | null;
isAuthenticated: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
}
export const useAuthStore = create<AuthStore>()(
devtools(
persist(
immer((set) => ({
user: null,
token: null,
isAuthenticated: false,
login: async (email, password) => {
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (!res.ok) throw new Error("認証に失敗しました");
const { user, token } = await res.json();
set((state) => {
state.user = user;
state.token = token;
state.isAuthenticated = true;
});
},
logout: () => {
set((state) => {
state.user = null;
state.token = null;
state.isAuthenticated = false;
});
},
})),
{
name: "auth-store",
partialize: (state) => ({ user: state.user, token: state.token }),
}
),
{ name: "AuthStore", enabled: process.env.NODE_ENV === "development" }
)
);
セレクタの正しい使い方
セレクタのベストプラクティス
import { useShallow } from "zustand/shallow";
// NG: オブジェクト全体を購読(すべてのフィールド変更で再レンダリング)
const { user, isAuthenticated } = useAuthStore();
// OK: 個別セレクタ(必要な値だけ購読)
const user = useAuthStore((state) => state.user);
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
// OK: 複数値を浅い比較で購読(useShallow)
const { user, isAuthenticated } = useAuthStore(
useShallow((state) => ({
user: state.user,
isAuthenticated: state.isAuthenticated,
}))
);
// 派生値はセレクタ関数として定義(コンポーネント外)
const selectDisplayName = (state: AuthStore) =>
state.user ? `${state.user.name}` : "ゲスト";
function UserGreeting() {
const displayName = useAuthStore(selectDisplayName);
return <p>こんにちは、{displayName}さん</p>;
}
useShallowはZustand v4.4.2で導入され、v5でも引き続き推奨されるフックです。オブジェクトの浅い比較で再レンダリングを最適化します。複数の値をまとめて取得する場合はこれを使ってください。Jotai v2 ── 細粒度の共有状態
Jotaiは「atom」単位で状態を定義するライブラリで、Zustandと同じメンテナー(dai-shi / Daishi Kato)が開発に携わっています。Zustandとの共存を前提に作られており、細かいUI状態の共有に適しています。
| 判断基準 | Zustand | Jotai |
|---|---|---|
| 状態の粒度 | 粗い(ストア単位) | 細かい(atom単位) |
| 更新パターン | 複数の値を一括更新 | 個別の値を独立更新 |
| 適するケース | 認証、設定、UI全体 | フィルタ、ソート、個別トグル |
| React外アクセス | 可能(getState()) | 不可(React専用) |
| 再レンダリング | セレクタで手動最適化 | atom単位で自動最適化 |
atoms/filters.ts(Jotaiの使い方)
import { atom } from "jotai";
// 基本atom
export const searchQueryAtom = atom("");
export const sortOrderAtom = atom<"asc" | "desc">("desc");
export const selectedCategoryAtom = atom<string | null>(null);
// derived atom(読み取り専用:他のatomから計算)
export const activeFilterCountAtom = atom((get) => {
let count = 0;
if (get(searchQueryAtom)) count++;
if (get(selectedCategoryAtom)) count++;
if (get(sortOrderAtom) !== "desc") count++;
return count;
});
// writable derived atom(読み書き:リセット機能)
export const resetFiltersAtom = atom(null, (_get, set) => {
set(searchQueryAtom, "");
set(sortOrderAtom, "desc");
set(selectedCategoryAtom, null);
});
Jotaiをコンポーネントで使う
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { searchQueryAtom, activeFilterCountAtom, resetFiltersAtom } from "@/atoms/filters";
function FilterBar() {
const [query, setQuery] = useAtom(searchQueryAtom);
const filterCount = useAtomValue(activeFilterCountAtom);
const resetFilters = useSetAtom(resetFiltersAtom);
return (
<div>
<input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="検索..." />
<span>アクティブフィルタ: {filterCount}</span>
<button onClick={() => resetFilters()}>リセット</button>
</div>
);
}
使い分けの実践ルール:「1つの操作で複数の値が同時に変わる」→Zustand(例:ログインでuser+token+isAuth)。「独立した小さな値が多数ある」→Jotai(例:ダッシュボードの各フィルタ状態)。
TanStack Query v5 ── サーバー状態管理
APIから取得したデータは「サーバー状態」であり、クライアント状態とは根本的に異なります。TanStack Queryはキャッシュ・再検証・ローディング・エラー処理を自動化し、サーバー状態を正しく管理します。
hooks/queries/useTodos.ts
import { useQuery, useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import type { Todo, NewTodo } from "@/types";
// データ取得(キャッシュ5分、GC30分)
export function useTodos(filter?: string) {
return useQuery({
queryKey: ["todos", "list", { filter }],
queryFn: () => api.getTodos({ filter }),
staleTime: 5 * 60 * 1000,
gcTime: 30 * 60 * 1000, // v5: cacheTime → gcTime
});
}
// Suspense版(data が undefined にならない)
export function useTodosSuspense(filter?: string) {
return useSuspenseQuery({
queryKey: ["todos", "list", { filter }],
queryFn: () => api.getTodos({ filter }),
});
}
// 楽観的更新付きミューテーション
export function useAddTodo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (newTodo: NewTodo) => api.addTodo(newTodo),
onMutate: async (newTodo) => {
await queryClient.cancelQueries({ queryKey: ["todos"] });
const previous = queryClient.getQueryData<Todo[]>(["todos", "list"]);
// 楽観的にUIを更新
queryClient.setQueryData<Todo[]>(["todos", "list"], (old = []) => [
...old,
{ ...newTodo, id: crypto.randomUUID(), completed: false },
]);
return { previous };
},
onError: (_err, _newTodo, context) => {
// エラー時はロールバック
if (context?.previous) {
queryClient.setQueryData(["todos", "list"], context.previous);
}
},
onSettled: () => {
// 成功・失敗にかかわらずサーバーから再取得
queryClient.invalidateQueries({ queryKey: ["todos"] });
},
});
}
3ライブラリ統合 ── 実践的なコンポーネント設計
Todoページ(3ライブラリ統合例)
import { useState } from "react";
import { useAtom, useAtomValue } from "jotai";
import { useAuthStore } from "@/stores/auth";
import { searchQueryAtom, selectedCategoryAtom, activeFilterCountAtom } from "@/atoms/filters";
import { useTodosSuspense, useAddTodo } from "@/hooks/queries/useTodos";
export function TodoPage() {
// Layer 4: Global State(Zustand)
const user = useAuthStore((s) => s.user);
// Layer 3: Atomic State(Jotai)
const [query] = useAtom(searchQueryAtom);
const [category] = useAtom(selectedCategoryAtom);
const filterCount = useAtomValue(activeFilterCountAtom);
// Layer 5: Server State(TanStack Query)
const { data: todos } = useTodosSuspense(query);
const addTodo = useAddTodo();
// Layer 2: Local State(useState)
const [isFormOpen, setIsFormOpen] = useState(false);
return (
<div>
<header>
<span>{user?.name}さんのTodo</span>
<span>フィルタ: {filterCount}</span>
</header>
<FilterBar />
<TodoList todos={todos} />
<button onClick={() => setIsFormOpen(true)}>追加</button>
{isFormOpen && (
<CreateTodoForm
onSubmit={(data) => addTodo.mutate(data)}
onClose={() => setIsFormOpen(false)}
/>
)}
</div>
);
}
この例では5層のうち4層が1つのコンポーネントで共存しています。各層の境界が明確なので、Claude Codeにコンポーネントの追加や修正を依頼しても、状態の管理先を正しく選んでくれます。
よくあるアンチパターン
| アンチパターン | 問題 | 正しいアプローチ |
|---|---|---|
APIレスポンスをuseStateに入れる |
キャッシュ・再検証・ローディング状態を手動管理する必要がある | TanStack Queryに任せる |
| すべてをZustandに入れる | ストアが肥大化し、セレクタの最適化が困難になる | 5層に分離する |
| Zustandでオブジェクト全体を購読する | 関係ないフィールドの変更でも再レンダリングが発生する | 個別セレクタまたはuseShallow |
| propsのバケツリレーを避けるためだけにグローバル化 | 不必要なグローバル状態が増える | コンポーネント合成やContextで解決 |
| TanStack Queryのキーに文字列を使う | キャッシュの一括無効化ができない | 配列形式で階層的に(["todos", "list", { filter }]) |
よくある質問
QReduxはもう使わないほうがいいですか?
A新規プロジェクトではZustandを推奨します。Reduxは依然として大規模エンタープライズで使われていますが、ボイラープレートの多さ(action/reducer/slice定義)がZustandと比較して開発速度を下げます。既存のReduxプロジェクトを無理に移行する必要はありませんが、新規ならZustand(約1.1KB)のほうがシンプルで高速です。
QZustandとJotaiの両方を使うのは冗長ではないですか?
A冗長ではありません。2つは同じ作者が「共存前提」で設計しています。Zustandは「1つの操作で複数の値が同時に変わる」粗い粒度の状態に、Jotaiは「独立した小さな値が多数ある」細かい粒度の状態に適しています。プロジェクトの規模が小さい場合はZustandだけでも十分です。
QTanStack QueryとSWRのどちらを選ぶべきですか?
ATanStack Query v5は楽観的更新・Suspense統合・SSRプリフェッチ・構造化されたクエリキーなど機能が豊富です。SWRはシンプルさが強みで小規模プロジェクトに向いています。Claude Codeとの相性では、TanStack Queryの方が明確なパターン(queryKey/queryFn/staleTime/gcTime)があるため、CLAUDE.mdでの指示がしやすいです。
QServer ComponentsでZustand/Jotaiは使えますか?
AServer Componentsはサーバーで実行されるため、クライアントの状態管理ライブラリ(Zustand/Jotai)は直接使えません。状態管理が必要な部分は
'use client'を付けたClient Componentに分離してください。Server Componentsではデータ取得を直接行い(async/await)、Client Componentsにpropsとして渡すのが基本パターンです。QClaude Codeにリファクタリングを依頼するとき、状態管理の移行は安全にできますか?
ACLAUDE.mdに5層モデルのルールを書いておけば安全です。たとえば「このコンポーネントのuseState APIデータをTanStack Queryに移行して」と依頼すると、Claude Codeがキャッシュ・ローディング状態・エラーハンドリングを含んだ正しいパターンで書き換えてくれます。移行前に
/rewindでチェックポイントを作っておけば、問題があればすぐに戻せます。まとめ
- 5層モデル: Server State→Global→Atomic→Local→Formの5層で状態を分類し、各層に最適なライブラリを使う
- Zustand v5: グローバル状態管理のデファクト。persist/immer/devtoolsミドルウェアで機能拡張。セレクタは個別購読が鉄則
- Jotai v2: 細粒度の共有状態。atom単位で自動的に再レンダリングを最適化
- TanStack Query v5: APIデータの唯一の管理先。useSuspenseQueryでSuspense統合、楽観的更新で快適なUX
- CLAUDE.md: 5層ルールを書いておけばClaude Codeが正しい層にデータを配置するコードを生成する
Next.jsとの統合はClaude Code × Next.js完全ガイド、パフォーマンス最適化はClaude Codeパフォーマンス最適化ガイド、リアルタイム通信はClaude Code × リアルタイムアプリ開発ガイドもあわせてご覧ください。
