Claude CodeでReact状態管理を安全に整理する実践ガイド
React状態管理をClaude Codeで見直す判断基準と実装例。Context、Zustand、Jotai、TanStack Queryを比較。
Reactの状態管理は、最初からライブラリを入れれば解決する話ではありません。入力欄の値、モーダルの開閉、カート、ユーザー設定、APIから取得した商品一覧は、同じ「state」に見えても寿命も責任範囲も違います。
Claude Codeに「状態管理をいい感じに直して」と頼むだけだと、不要なグローバルストア、重複したキャッシュ、テストしにくい副作用が増えます。逆に、どの状態をReact標準で残し、どこからZustand、Jotai、TanStack Queryに切り出すかを言語化してから依頼すると、差分がかなり読みやすくなります。
この記事では、初心者でも判断できるように、クライアント状態とサーバー状態の分け方、todo、cart、settings、商品一覧の4つの実例、テスト、失敗例、Claude Codeへの安全なリファクタリング依頼文までまとめます。
まず状態を4種類に分ける
状態管理の設計で最初にやることは、ライブラリ選定ではなく分類です。分類を間違えると、単純なUI状態を大げさなストアに入れたり、APIレスポンスを手作りキャッシュで抱え込んだりします。
flowchart TD
A["React state management"] --> B["Local UI state"]
A --> C["Shared client state"]
A --> D["Persisted preferences"]
A --> E["Server state"]
B --> B1["input, modal, tab"]
C --> C1["cart, wizard, editor"]
D --> D1["theme, density, locale"]
E --> E1["products, orders, profile from API"]
ローカルUI状態は、ひとつのコンポーネントや近い親子だけで完結する状態です。useStateやuseReducerで十分なことが多いです。共有クライアント状態は、離れたコンポーネントが同じ値を読み書きする状態です。Context、Zustand、Jotaiの候補になります。
永続化したい設定は、ブラウザのlocalStorageやCookieと関係します。ここではZustandのpersistやJotaiのatomWithStorageが便利です。サーバー状態は、APIの向こう側に本体があり、他ユーザーや別端末で変わる可能性があります。これはTanStack Queryのようなデータ取得ライブラリに寄せる方が安全です。
React標準で足りるケース
React公式のManaging Stateでは、状態を構造化し、必要なら親へ持ち上げ、複雑になったらreducerとcontextを組み合わせる流れが説明されています。Claude Codeに依頼する前も、この順番で考えると過剰設計を避けられます。
| 状態の種類 | まず試す方法 | ライブラリを検討する合図 |
|---|---|---|
| 入力欄、タブ、モーダル | useState | ほぼ不要 |
| 複数アクションがある画面 | useReducer | reducerが複数画面へ広がる |
| 深い階層へ渡す値 | Context | 更新頻度が高く再レンダーが重い |
| カート、編集ドラフト | Zustand | 画面をまたいで読み書きする |
| テーマ、表示密度 | Jotai | 小さな設定を独立して組み合わせる |
| 商品一覧、注文、プロフィールAPI | TanStack Query | キャッシュ、再取得、失敗時の扱いが必要 |
初心者向けの目安は、「同じ値を3か所以上で更新する」「別ページでも使う」「リロード後も残したい」「APIから取る」のどれかに当てはまるかです。どれにも当てはまらないなら、まずReact標準で十分です。
実例1: todoはReducerとContextで始める
todoやチェックリストは、状態管理の練習に向いています。追加、完了切り替え、削除というアクションがあり、まだサーバー同期は不要だからです。ここでいきなりZustandを入れるより、まずreducerで「何が起きたか」を表現します。
import {
createContext,
useContext,
useMemo,
useReducer,
type Dispatch,
type ReactNode,
} from "react";
export type Todo = {
id: string;
title: string;
done: boolean;
};
type TodoAction =
| { type: "added"; title: string }
| { type: "toggled"; id: string }
| { type: "deleted"; id: string };
export function todoReducer(todos: Todo[], action: TodoAction): Todo[] {
switch (action.type) {
case "added":
return [
...todos,
{ id: crypto.randomUUID(), title: action.title.trim(), done: false },
];
case "toggled":
return todos.map((todo) =>
todo.id === action.id ? { ...todo, done: !todo.done } : todo,
);
case "deleted":
return todos.filter((todo) => todo.id !== action.id);
default:
return todos;
}
}
const TodoStateContext = createContext<Todo[] | null>(null);
const TodoDispatchContext = createContext<Dispatch<TodoAction> | null>(null);
export function TodoProvider({ children }: { children: ReactNode }) {
const [todos, dispatch] = useReducer(todoReducer, []);
const stateValue = useMemo(() => todos, [todos]);
return (
<TodoStateContext.Provider value={stateValue}>
<TodoDispatchContext.Provider value={dispatch}>
{children}
</TodoDispatchContext.Provider>
</TodoStateContext.Provider>
);
}
export function useTodos() {
const value = useContext(TodoStateContext);
if (value === null) throw new Error("useTodos must be used in TodoProvider");
return value;
}
export function useTodoDispatch() {
const value = useContext(TodoDispatchContext);
if (value === null) {
throw new Error("useTodoDispatch must be used in TodoProvider");
}
return value;
}
この形の良い点は、Claude Codeが差分を読みやすいことです。「addedに空文字チェックを追加して」「deletedの前に確認ダイアログを入れて」など、変更単位が明確になります。Contextで状態とdispatchを分けているので、読み取りだけのコンポーネントと更新するコンポーネントも分けやすくなります。
実例2: カートはZustandで画面をまたぐ
ECサイトのカートは、ヘッダー、商品詳細、カートページ、購入導線が同じ状態を参照します。この場合はContextだけでも作れますが、更新関数、選択、永続化が増えるとZustandのような小さなストアが扱いやすくなります。
Zustand公式ドキュメントのcreateは、フックとして使えるストアを作ります。persist middlewareはページ再読み込み後も状態を残す用途に使えます。ただし、決済金額や在庫の最終判断は必ずサーバー側で行います。ブラウザ上のカートは「購入前の希望リスト」に近いものです。
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
export type CartItem = {
productId: string;
name: string;
price: number;
quantity: number;
};
type CartState = {
items: CartItem[];
currency: "JPY" | "USD";
addItem: (item: Omit<CartItem, "quantity">) => void;
removeItem: (productId: string) => void;
setQuantity: (productId: string, quantity: number) => void;
clear: () => void;
total: () => number;
};
export const useCartStore = create<CartState>()(
persist(
(set, get) => ({
items: [],
currency: "JPY",
addItem: (item) =>
set((state) => {
const current = state.items.find(
(entry) => entry.productId === item.productId,
);
if (current) {
return {
items: state.items.map((entry) =>
entry.productId === item.productId
? { ...entry, quantity: entry.quantity + 1 }
: entry,
),
};
}
return { items: [...state.items, { ...item, quantity: 1 }] };
}),
removeItem: (productId) =>
set((state) => ({
items: state.items.filter((item) => item.productId !== productId),
})),
setQuantity: (productId, quantity) =>
set((state) => ({
items: state.items.map((item) =>
item.productId === productId
? { ...item, quantity: Math.max(1, quantity) }
: item,
),
})),
clear: () => set({ items: [] }),
total: () =>
get().items.reduce((sum, item) => sum + item.price * item.quantity, 0),
}),
{
name: "cart-v1",
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({
items: state.items,
currency: state.currency,
}),
},
),
);
Claude Codeへ依頼するときは「Zustandへ移して」では足りません。「永続化するのはitemsとcurrencyだけ」「価格の信頼元はAPI」「SSRでlocalStorageに触る場所を分離」といった制約を書きます。ここを省くと、トークンやユーザー情報まで永続化する危険があります。
実例3: 設定UIはJotaiで小さく分ける
テーマ、表示密度、サイドバー開閉、プレビュー有無のような設定は、ひとつの大きなオブジェクトに詰め込むより、小さな単位で扱う方が変更に強いです。Jotaiはatomという小さな状態単位を作り、派生atomで表示用の値を組み立てられます。
import { atom } from "jotai";
import { atomWithStorage } from "jotai/utils";
export const themeAtom = atomWithStorage<"light" | "dark">(
"settings.theme",
"light",
);
export const densityAtom = atomWithStorage<"comfortable" | "compact">(
"settings.density",
"comfortable",
);
export const sidebarOpenAtom = atom(true);
export const settingsLabelAtom = atom((get) => {
const theme = get(themeAtom) === "dark" ? "dark theme" : "light theme";
const density =
get(densityAtom) === "compact" ? "compact layout" : "comfortable layout";
return `${theme}, ${density}`;
});
export const resetSettingsAtom = atom(null, (_get, set) => {
set(themeAtom, "light");
set(densityAtom, "comfortable");
set(sidebarOpenAtom, true);
});
Jotaiは、設定のように独立した小さな状態が多い画面で使いやすいです。一方、業務ルールが密集した大きな状態遷移をすべてatomへ分解すると、どこで何が変わるか追いにくくなります。Claude Codeには「atomを増やしすぎない」「派生値はatomにするが、APIキャッシュはTanStack Queryに残す」と伝えるのが実務的です。
実例4: 商品一覧や注文はTanStack Queryに任せる
商品一覧、注文履歴、プロフィールなどはサーバー状態です。サーバー状態は、取得中、失敗、再取得、キャッシュ期限、同じリクエストの重複排除、更新後のinvalidateを考える必要があります。TanStack Query v5は、こうしたサーバー状態の取得、キャッシュ、同期、更新を扱うためのライブラリです。
import {
QueryClient,
QueryClientProvider,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import type { ReactNode } from "react";
type Product = {
id: string;
name: string;
price: number;
stock: number;
};
const queryClient = new QueryClient();
export function ProductsProvider({ children }: { children: ReactNode }) {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}
async function fetchProducts(category: string): Promise<Product[]> {
const response = await fetch(`/api/products?category=${category}`);
if (!response.ok) throw new Error("Failed to fetch products");
return response.json() as Promise<Product[]>;
}
async function addCartLine(productId: string) {
const response = await fetch("/api/cart/lines", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ productId }),
});
if (!response.ok) throw new Error("Failed to add cart line");
return response.json();
}
export function useProducts(category: string) {
return useQuery({
queryKey: ["products", category],
queryFn: () => fetchProducts(category),
staleTime: 60_000,
});
}
export function useAddCartLine() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: addCartLine,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["cart"] });
},
});
}
ここで大事なのは、ZustandのカートとTanStack QueryのカートAPIを混ぜないことです。画面上の一時的なカート操作はZustandでよくても、サーバー上の注文、在庫、決済結果はTanStack Queryで取得し直します。支払い直前にブラウザのローカル状態だけを信用してはいけません。
Claude Codeに安全にリファクタリングさせるプロンプト
Claude Code公式のHow Claude Code worksでは、コンテキスト収集、実行、検証を繰り返すエージェントの流れが説明されています。状態管理のリファクタリングでも、この流れをプロンプトで固定すると失敗が減ります。
このReactアプリの状態管理を調査してください。まだ編集しないでください。
目的:
- client state と server state を分ける
- React標準で十分な箇所は残す
- Zustand/Jotai/TanStack Queryを入れるなら理由を書く
出力:
- 現在の状態一覧
- 重複している状態
- APIレスポンスを手作り管理している箇所
- 小さく安全に直す順番
次に、実装を小さく分けて依頼します。
前回の調査結果のうち、todo画面だけをuseReducer + Contextに移してください。
制約:
- public APIと画面文言は変えない
- 既存テストがあれば更新する
- 新しいライブラリは追加しない
- 変更ファイルと検証コマンドを最後に報告する
ZustandやTanStack Queryを入れる段階では、依存追加、Provider追加、既存データの移行、テスト範囲を別々にします。大きな一括変更より、1画面ずつ通す方がレビューしやすく、他エージェントが同時に作業しているリポジトリでも衝突を減らせます。
テストで守るポイント
状態管理のテストは、UI全部をE2Eでなぞる前に、reducerやストアの純粋なロジックを確認します。たとえばtodoのreducerは、Reactを起動しなくてもVitestで検証できます。
import { describe, expect, it, vi } from "vitest";
import { todoReducer, type Todo } from "./TodoProvider";
describe("todoReducer", () => {
it("adds a todo with a generated id", () => {
vi.spyOn(crypto, "randomUUID").mockReturnValue("todo-1");
const result = todoReducer([], { type: "added", title: "Write tests" });
expect(result).toEqual([
{ id: "todo-1", title: "Write tests", done: false },
]);
});
it("toggles a todo", () => {
const initial: Todo[] = [{ id: "todo-1", title: "Ship", done: false }];
const result = todoReducer(initial, { type: "toggled", id: "todo-1" });
expect(result[0].done).toBe(true);
});
});
TanStack Queryは公式にtesting guideがあります。テストごとに新しいQueryClientを作り、キャッシュが別テストへ漏れないようにします。Claude Codeへは「reducerの単体テスト」「ストア操作の単体テスト」「APIはMSWでmock」「最後に手動確認」のように階層を分けて頼むと安全です。
よくある失敗と落とし穴
一つ目の失敗は、すべての状態をグローバルストアへ入れることです。モーダルの開閉や入力中の文字列まで共有ストアに入れると、読む場所が増え、不要な再レンダーや意図しない保持が起きます。
二つ目は、サーバー状態をZustandやContextだけで抱えることです。商品一覧をuseEffectで取得し、ローディング、エラー、再取得、キャッシュ破棄をすべて自作すると、実装がすぐ膨らみます。API由来のデータはTanStack Queryを検討します。
三つ目は、派生値を重複保存することです。itemsがあるのにtotalPriceもstateとして持つと、数量変更時に更新漏れが起きます。合計金額はselectorや関数で計算し、保存する値を減らします。
四つ目は、永続化のしすぎです。localStorageにアクセストークン、個人情報、サーバーから再取得すべき権限情報を入れると危険です。永続化するのはテーマ、表示密度、カートの仮データなど、漏れても影響を限定できるものに絞ります。
五つ目は、Claude Codeの差分を検証せずに信じることです。状態管理の修正は画面が動いて見えても、リロード後、別タブ、API失敗、低速回線で壊れます。リファクタリング後はnpm test、npm run build、対象画面の手動確認をセットにします。
学習リンクと収益導線
公式情報は、ReactのManaging State、Zustandのpersist middleware、Jotaiの公式ドキュメント、TanStack QueryのOverview、Claude CodeのBest practicesを確認してください。
内部リンクとしては、Claude Codeへの頼み方を整えるならプロンプトテクニック完全ガイド、日常作業の型を作るならClaude Code生産性を3倍にするTipsが続きとして読みやすいです。
状態管理の設計をチームの標準にするなら、CLAUDE.md、レビュー観点、テストコマンド、導入順序をまとめたテンプレートが必要です。ClaudeCodeLabでは、個人向けの教材・テンプレートと、チーム向けのClaude Code研修・相談を用意しています。記事のコードを試したあと、自社リポジトリで安全に進めたい場合は、まず既存stateの棚卸しから始めるのが現実的です。
この記事で紹介した内容を実際に試した結果、最も効果があったのは「server stateを先に切り出す」ことでした。API由来のデータをTanStack Queryへ寄せるだけで、手作りのloading/error/cache処理が減り、ZustandやJotaiは本当に必要なクライアント状態だけに絞れました。Claude Codeには最初から実装を頼むより、状態一覧、移行順、テスト方針を先に出させた方が、レビューできる差分になりやすかったです。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
Claude Code権限セーフティラダー: 初心者がallowを広げる順番
Claude Codeの権限をread-onlyからbuild、限定編集、deploy確認まで段階的に広げる安全な運用手順。
Claude Code Small PR Proof Pack: 小さなPRをレビュー可能にする証拠セット
Claude Codeの小さなPRに、差分・検証・公開URL・CTA・rollbackを添える実務チェックリスト。
Claude Codeのコミット前レビューゲート: 差分、テスト、CTAをまとめて止める型
Claude Codeでcommit前に差分をレビューする実践手順。build、公開URL、CTA、Gumroadリンク、未翻訳本文を検知します。