Claude CodeでZustand状態管理を設計する実装ガイド
Claude CodeでZustandのstore、selector、persist、非同期action、テストを実例つきで設計します。
ZustandをClaude Codeに任せる前に決めること
ZustandはReact向けの軽量な状態管理ライブラリです。状態管理とは、画面にまたがって共有したい値と、その値を更新する処理を整理することです。Claude Codeに「Zustandでstoreを作って」と頼むだけでもコードは出ますが、最初に境界を決めないと、検索フォームの一時入力、認証情報、通知、サーバーから取得した一覧データまで全部がglobal storeに入ってしまいます。
この記事では、初心者がその事故を避けながら、Claude Codeと一緒にZustand storeを設計する手順をまとめます。扱うユースケースは、管理画面フィルタ、カート、認証UI状態、モーダル/トースト、楽観更新です。コードはTypeScriptで、store、selector、persistの部分化、非同期action、テスト、Claudeへのレビュー指示までコピーできる形にしています。
公式仕様はZustand公式ドキュメント、永続化はpersist middleware、再レンダリング対策はuseShallowガイドを基準にしています。サーバー状態との分担はClaude CodeでTanStack Queryを活用するガイド、より小さい粒度の状態はClaude CodeでJotai atomsを設計する記事も合わせて確認してください。
Zustandに入れる状態と入れない状態
Zustandは便利ですが、何でも入れる場所ではありません。基本は「複数の離れたコンポーネントで同じ値を使う」「URLだけでは表しにくいUI状態がある」「更新処理をひとつの場所でテストしたい」という条件を満たすものだけを入れます。サーバーから取得した商品一覧やユーザー一覧は、キャッシュ、再取得、stale判定が必要なので、TanStack Queryなどに任せる方が自然です。
| ユースケース | Zustandに入れる値 | 入れない値 | Claude Codeへの伝え方 |
|---|---|---|---|
| 管理画面フィルタ | keyword、status、page、pageSize | APIレスポンス全体 | URL同期する項目とUIだけの項目を分ける |
| カート | SKU、数量、税込価格の表示用合計 | 決済セッション、在庫の真実 | localStorageへ保存する項目を限定する |
| 認証UI状態 | ログインダイアログの開閉、確認中表示 | access token、メール、住所 | 個人情報はstoreにもpersistにも入れない |
| モーダル/トースト | activeModal、toast queue | 長文エラーログ、監査ログ | 画面表示に必要な短い文だけ保持する |
| 楽観更新 | 処理中requestId、直前の表示状態 | サーバーの最終確定データ | 失敗時に戻す値と競合条件を指定する |
Masaが管理画面を改善したときに失敗したのは、最初から「状態管理ライブラリを使う」ことを目的にした点でした。検索フォームの入力中テキスト、URLクエリ、API取得結果、一覧の選択状態を同じstoreに入れたため、ページ遷移後にも古い検索条件が残りました。後から直すより、最初に「globalに残ってよい値」を表にする方が速いです。
flowchart LR
UI[React components] --> Selectors[Selectors and useShallow]
Selectors --> Store[Zustand store]
Store --> Actions[Actions]
Actions --> API[API and server state]
Store --> Persist[persist partialize]
Persist --> Storage[localStorage safe fields]
TypeScript storeの実装
まずはUI状態をまとめたstoreです。管理画面フィルタ、カート、認証UI、モーダル、トーストをひとつの例に入れています。実務ではファイルを分けても構いませんが、初心者がClaude Codeに依頼するときは、先にこのような最小の完成形を作らせるとレビューしやすくなります。
import { create, type StateCreator } from "zustand";
export type OrderStatus = "all" | "paid" | "refunded" | "failed";
export type AuthUiStatus = "anonymous" | "checking" | "signedIn";
export type ModalId = "invite-user" | "cart-drawer" | "delete-order" | null;
export interface AdminFilters {
keyword: string;
status: OrderStatus;
page: number;
pageSize: number;
}
export interface CartLine {
id: string;
name: string;
price: number;
quantity: number;
}
export interface AuthUi {
status: AuthUiStatus;
loginDialogOpen: boolean;
}
export interface Toast {
id: string;
kind: "success" | "error" | "info";
message: string;
}
export interface CommerceUiState {
filters: AdminFilters;
cart: CartLine[];
auth: AuthUi;
activeModal: ModalId;
toasts: Toast[];
setFilters: (patch: Partial<AdminFilters>) => void;
resetFilters: () => void;
addToCart: (line: Omit<CartLine, "quantity">) => void;
removeFromCart: (id: string) => void;
updateQuantity: (id: string, quantity: number) => void;
setAuthStatus: (status: AuthUiStatus) => void;
setLoginDialogOpen: (open: boolean) => void;
openModal: (modal: Exclude<ModalId, null>) => void;
closeModal: () => void;
pushToast: (toast: Omit<Toast, "id">) => void;
dismissToast: (id: string) => void;
checkoutTotal: () => number;
}
export const initialFilters: AdminFilters = {
keyword: "",
status: "all",
page: 1,
pageSize: 25,
};
const createToastId = () =>
globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2);
export const createCommerceUiSlice: StateCreator<CommerceUiState> = (set, get) => ({
filters: initialFilters,
cart: [],
auth: { status: "anonymous", loginDialogOpen: false },
activeModal: null,
toasts: [],
setFilters: (patch) =>
set((state) => ({
filters: {
...state.filters,
...patch,
page: patch.page ?? 1,
},
})),
resetFilters: () => set({ filters: initialFilters }),
addToCart: (line) =>
set((state) => {
const current = state.cart.find((item) => item.id === line.id);
if (!current) {
return { cart: [...state.cart, { ...line, quantity: 1 }] };
}
return {
cart: state.cart.map((item) =>
item.id === line.id ? { ...item, quantity: item.quantity + 1 } : item,
),
};
}),
removeFromCart: (id) =>
set((state) => ({
cart: state.cart.filter((item) => item.id !== id),
})),
updateQuantity: (id, quantity) =>
set((state) => ({
cart: state.cart
.map((item) =>
item.id === id ? { ...item, quantity: Math.max(0, quantity) } : item,
)
.filter((item) => item.quantity > 0),
})),
setAuthStatus: (status) =>
set((state) => ({
auth: { ...state.auth, status },
})),
setLoginDialogOpen: (open) =>
set((state) => ({
auth: { ...state.auth, loginDialogOpen: open },
})),
openModal: (modal) => set({ activeModal: modal }),
closeModal: () => set({ activeModal: null }),
pushToast: (toast) =>
set((state) => ({
toasts: [...state.toasts, { ...toast, id: createToastId() }],
})),
dismissToast: (id) =>
set((state) => ({
toasts: state.toasts.filter((toast) => toast.id !== id),
})),
checkoutTotal: () =>
get().cart.reduce((total, item) => total + item.price * item.quantity, 0),
});
export const useCommerceUiStore = create<CommerceUiState>()(createCommerceUiSlice);
ここで大切なのは、認証そのものではなく「認証に関するUI状態」だけを持つことです。access token、メールアドレス、住所、購入履歴の詳細をpersist対象にすると、localStorageから個人情報が読める状態になります。Claude Codeには「PII、つまり個人を特定できる情報は保存しない」と明示してください。
selectorで再レンダリングを抑える
Zustandはhookなので、コンポーネントから直接値を読めます。ただし useCommerceUiStore() のようにstore全体を購読すると、トーストが1件増えただけでカートバッジやフィルタバーまで再レンダリングされます。selectorとは、storeの中から必要な部分だけを選ぶ関数です。複数の値をオブジェクトで返す場合は useShallow を使うと、浅い比較で同じ値なら余計な更新を避けられます。
import { useShallow } from "zustand/react/shallow";
import {
useCommerceUiStore,
type CommerceUiState,
} from "./commerce-ui-store";
export const selectCartCount = (state: CommerceUiState) =>
state.cart.reduce((sum, item) => sum + item.quantity, 0);
export const selectCartTotal = (state: CommerceUiState) => state.checkoutTotal();
export function CartBadge() {
const count = useCommerceUiStore(selectCartCount);
return <button type="button">Cart ({count})</button>;
}
export function AdminFilterSummary() {
const { filters, setFilters, resetFilters } = useCommerceUiStore(
useShallow((state) => ({
filters: state.filters,
setFilters: state.setFilters,
resetFilters: state.resetFilters,
})),
);
return (
<form>
<input
value={filters.keyword}
placeholder="Search orders"
onChange={(event) => setFilters({ keyword: event.currentTarget.value })}
/>
<select
value={filters.status}
onChange={(event) =>
setFilters({ status: event.currentTarget.value as CommerceUiState["filters"]["status"] })
}
>
<option value="all">All</option>
<option value="paid">Paid</option>
<option value="refunded">Refunded</option>
<option value="failed">Failed</option>
</select>
<output>Page {filters.page}</output>
<button type="button" onClick={resetFilters}>
Reset
</button>
</form>
);
}
Claude Codeにレビューさせるときは「コンポーネントがstore全体を購読していないか」「selectorが毎回新しい配列やオブジェクトを返していないか」を確認対象に入れます。ここを省くと、UIが小さいうちは問題が見えず、管理画面にテーブル、サイドバー、通知、検索条件が増えた時点で急に重くなります。
persistはpartializeで必要最小限にする
persist middlewareはリロード後も状態を残すために便利です。ただし、残す値を絞らないpersistは危険です。カートやフィルタは保存してもよい一方、モーダル、トースト、認証UI、処理中requestIdはリロード後に復元されると不自然です。以下の例は partialize で保存対象を filters と cart に限定し、SSR環境では安全なstorageを返します。
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import {
createCommerceUiSlice,
type CommerceUiState,
} from "./commerce-ui-store";
const noopStorage = {
getItem: () => null,
setItem: () => undefined,
removeItem: () => undefined,
};
type PersistedCommerceUiState = Pick<CommerceUiState, "filters" | "cart">;
export const usePersistedCommerceUiStore = create<CommerceUiState>()(
persist(createCommerceUiSlice, {
name: "commerce-ui-v1",
version: 1,
storage: createJSONStorage(() =>
typeof window === "undefined" ? noopStorage : window.localStorage,
),
partialize: (state): PersistedCommerceUiState => ({
filters: state.filters,
cart: state.cart,
}),
}),
);
Next.jsやAstroのようにSSRがある環境では、サーバーで生成したHTMLとブラウザで復元した状態が違うとhydration不一致が起きます。たとえばサーバーではカート0件、ブラウザではlocalStorageから3件復元という差です。バッジや合計金額はマウント後に表示する、またはSSRで表示しない領域に切り分ける設計をClaude Codeへ伝えてください。
非同期actionと楽観更新
楽観更新は、サーバーの成功を待たずに画面だけ先に変える手法です。フォロー、いいね、カート数量変更のように、失敗率が低くてユーザーの待ち時間を短くしたい場面に向きます。ただし、非同期actionが競合すると、古いレスポンスが新しい状態を上書きします。requestIdを持ち、失敗時に戻す値を保存するのが最低ラインです。
import { create } from "zustand";
interface Profile {
id: string;
name: string;
isFollowing: boolean;
}
interface ProfileState {
profiles: Record<string, Profile>;
followRequestIds: Record<string, string>;
setProfile: (profile: Profile) => void;
followOptimistically: (profileId: string) => Promise<void>;
}
const removeKey = <T,>(record: Record<string, T>, key: string) => {
const { [key]: _removed, ...rest } = record;
return rest;
};
async function updateFollowOnServer(profileId: string, follow: boolean) {
const response = await fetch(`/api/profiles/${profileId}/follow`, {
method: follow ? "PUT" : "DELETE",
});
if (!response.ok) {
throw new Error(`Follow update failed: ${response.status}`);
}
}
export const useProfileStore = create<ProfileState>((set, get) => ({
profiles: {},
followRequestIds: {},
setProfile: (profile) =>
set((state) => ({
profiles: { ...state.profiles, [profile.id]: profile },
})),
followOptimistically: async (profileId) => {
const before = get().profiles[profileId];
if (!before) {
throw new Error("Profile not found");
}
const requestId = `${profileId}-${Date.now()}`;
set((state) => ({
profiles: {
...state.profiles,
[profileId]: { ...before, isFollowing: true },
},
followRequestIds: {
...state.followRequestIds,
[profileId]: requestId,
},
}));
try {
await updateFollowOnServer(profileId, true);
} catch (error) {
set((state) => {
if (state.followRequestIds[profileId] !== requestId) return state;
return {
profiles: { ...state.profiles, [profileId]: before },
followRequestIds: removeKey(state.followRequestIds, profileId),
};
});
throw error;
}
set((state) => {
if (state.followRequestIds[profileId] !== requestId) return state;
return {
followRequestIds: removeKey(state.followRequestIds, profileId),
};
});
},
}));
Claude Codeには「失敗時に何を戻すか」「同じIDに対する連打を許すか」「戻したあとにトーストを出すか」を指定します。ここを曖昧にすると、Claudeはきれいなasync/awaitを書いてくれますが、実際のユーザー操作で二重送信や古いレスポンスに負ける可能性があります。
Vitestでstoreをテストする
Zustand storeはReactコンポーネントを描画しなくてもテストできます。重要なのは、各テストの前に初期状態へ戻すことです。公式のTesting guideでも、テスト間でstore状態をリセットする考え方が示されています。
import { beforeEach, describe, expect, it } from "vitest";
import { useCommerceUiStore } from "./commerce-ui-store";
const initialState = useCommerceUiStore.getInitialState();
beforeEach(() => {
useCommerceUiStore.setState(initialState, true);
});
describe("commerce ui store", () => {
it("increments cart quantity and calculates total", () => {
const store = useCommerceUiStore.getState();
store.addToCart({ id: "sku-1", name: "Workshop", price: 1200 });
store.addToCart({ id: "sku-1", name: "Workshop", price: 1200 });
expect(useCommerceUiStore.getState().cart[0]?.quantity).toBe(2);
expect(useCommerceUiStore.getState().checkoutTotal()).toBe(2400);
});
it("resets the page when a filter changes", () => {
useCommerceUiStore.getState().setFilters({ page: 4 });
useCommerceUiStore.getState().setFilters({ keyword: "refund" });
expect(useCommerceUiStore.getState().filters).toMatchObject({
keyword: "refund",
page: 1,
});
});
it("keeps auth UI and toasts explicit", () => {
useCommerceUiStore.getState().setAuthStatus("signedIn");
useCommerceUiStore.getState().pushToast({
kind: "success",
message: "Saved",
});
expect(useCommerceUiStore.getState().auth.status).toBe("signedIn");
expect(useCommerceUiStore.getState().toasts).toHaveLength(1);
});
});
テストは「コードが動くか」だけでなく、「設計の約束が守られているか」を確認する場です。フィルタ変更でpageが1に戻る、数量0でカート行が消える、persist対象が限定されている、楽観更新の失敗時に戻る。このような仕様を先にテスト名へ書くと、Claude Codeの修正も安定します。
Claude Codeへのレビュー指示
実装後は、生成させた本人であるClaude Codeにもう一度レビューさせます。ただし「レビューして」だけでは甘くなります。観点を固定し、blocking issue、修正案、追加テストを返すようにします。
このdiffのZustand状態管理だけをレビューしてください。
確認してほしい観点:
1. component local stateでよい値がglobal storeに入っていないか
2. selectorが広すぎて不要な再レンダリングを起こさないか
3. persistのpartializeがPII、token、modal、toast、requestIdを保存していないか
4. SSR/hydration不一致が起きる表示がないか
5. async actionが古いレスポンス、連打、失敗時rollbackに耐えるか
6. devtoolsやimmerを理由なく増やしていないか
7. Vitestで主要actionと失敗ケースが確認されているか
返答形式:
- Blocking issues
- Suggested patches
- Missing tests
- Questions before merge
この指示は、コード生成用ではなくレビュー用です。Claudeに実装とレビューを同じ文脈で頼むと、直前に自分が作った設計を肯定しがちです。別メッセージで「批判的に」「diffだけを対象に」と指定すると、selector不足やpersist漏れのような実務上の問題を拾いやすくなります。
よくある落とし穴
1つ目は、何でもglobal storeに入れることです。フォームの入力途中、hover状態、開閉がそのコンポーネント内で完結する小さなpopoverはlocal stateで十分です。Zustandに入れるほど、更新経路が遠くなり、テストやデバッグは複雑になります。
2つ目は、selector不足による再レンダリングです。useStore() で丸ごと購読するコードが混ざると、通知1件の更新で画面全体が反応します。Claude Codeには、すべてのReact componentでselectorを使うルールを明示してください。
3つ目は、persistによるPII漏れです。access token、メール、住所、法人名、問い合わせ内容をlocalStorageに置くと、XSS時の被害が広がります。保存するなら匿名のUI設定、カートのSKU、ページサイズのような低リスク項目に限定します。
4つ目は、SSR/hydration不一致です。サーバー側ではlocalStorageを読めません。初期HTMLとブラウザ復元後の表示が変わる値は、マウント後表示にするか、サーバー状態として別ルートで扱います。
5つ目は、非同期actionの競合です。連打、タブの切り替え、低速回線では、先に送ったrequestが後から返ることがあります。requestId、AbortController、rollbackのどれかを設計に入れます。
6つ目は、devtoolsやimmerの濫用です。便利なmiddlewareは、必要な理由があるときだけ入れます。immerはネストが深い更新では助けになりますが、単純な配列更新ならスプレッド構文の方が依存も少なく読みやすいです。
Claude Code Labで相談できること
Claude Code Labの研修・導入相談では、既存のReact管理画面やECサイトを題材に、Zustandへ移す状態、TanStack Queryへ残すサーバー状態、persistしてよい値、テスト観点、Claudeレビュー指示まで一緒に整理できます。単発のstore生成ではなく、チームが同じ判断基準で状態管理をレビューできるようにすることが目的です。
相談時は、画面一覧、現在のstore、localStorageに残っているkey、認証方式、SSRの有無、落ちているパフォーマンス指標を共有してください。特に、管理画面フィルタとカートのように「残したい状態」と「残すと危ない状態」が混ざるプロダクトでは、最初の棚卸しで実装の安全性が大きく変わります。
まとめと検証メモ
Zustandは、Claude Codeと相性のよい状態管理ライブラリです。ただし、良いstoreは「短いコード」ではなく「境界が明確なコード」です。管理画面フィルタ、カート、認証UI、モーダル/トースト、楽観更新を扱うなら、store、selector、persist、async action、テスト、レビュー指示をまとめて設計してください。
この記事で紹介した内容は、2026年6月2日にZustand公式ドキュメントの create、persist、useShallow、testing guideを確認し、TypeScriptの構文としてコピーできる形に整えました。実案件で試した結果、最初に「persistしてよい値」を表にしたチームほど、後からlocalStorageのPII削除やhydration不一致対応に戻る時間が減りました。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
ObsidianメモをCLAUDE.mdに変えるClaude Code運用: 文脈を毎回説明しない仕組み
Obsidianの作業メモからCLAUDE.md用の運用ノートを作り、Claude Codeに安定した文脈を渡す方法。
Claude Code Revenue CTA Routing: 記事からPDF、Gumroad、相談へ送る設計
PVだけで終わらせず、読者の状態に合わせて無料PDF、Gumroad教材、導入相談へ分岐するCTA設計です。
Claude Codeチーム引き継ぎルール: レビュー、権限、収益導線まで渡す実務手順
Claude Codeの作業をチームで渡すための証拠、権限、ロールバック、無料PDF/Gumroad/相談導線の実務ルール。