用 Claude Code 设计 Zustand 状态管理
用 Claude Code 设计 Zustand store、selector、persist、异步 action、测试与审查提示。
先决定状态边界
Zustand 是一个面向 React 的轻量状态管理库。这里的“状态管理”不是把所有变量放进一个全局对象,而是决定哪些值需要跨组件共享、哪些 action 可以修改它们、哪些值应该留在组件内部。Claude Code 很擅长快速生成 store,但如果没有边界,它也可能把搜索输入、认证信息、API 返回列表、toast、购物车全部塞进同一个 global store。
这篇文章把 Claude Code 当成状态设计助手来用,而不是只让它写代码。我们会覆盖五个常见场景:管理后台筛选器、购物车、认证 UI 状态、modal/toast、乐观更新。示例包含可复制的 TypeScript store、selector、persist 部分化、异步 action、Vitest 测试,以及让 Claude Code 进行严格审查的提示词。
技术依据以 Zustand 官方介绍、persist middleware 文档、useShallow 指南 为准。服务端数据与缓存的分工可以参考 Claude Code 的 TanStack Query 指南,更细粒度的原子状态可以对照 Jotai atoms 文章。
哪些状态适合放进 Zustand
判断标准很直接:多个距离较远的组件需要同一个值,更新逻辑需要集中测试,或者只靠 URL 难以表达 UI 状态时,才考虑 Zustand。商品列表、用户表格、搜索结果这类服务端数据通常不适合直接放进 Zustand,因为它们需要缓存、重新请求、过期判断、错误恢复,更适合 TanStack Query 等服务端状态工具。
| 场景 | 放进 Zustand | 不放进 Zustand | 给 Claude Code 的说明 |
|---|---|---|---|
| 管理后台筛选器 | keyword、status、page、pageSize | 完整 API 响应 | 区分 URL 同步值与纯 UI 值 |
| 购物车 | SKU、数量、展示价格 | 支付 session、真实库存 | 只持久化低风险字段 |
| 认证 UI | 登录弹窗开关、checking 状态 | access token、邮箱、地址 | 不保存 PII 或密钥 |
| Modal/toast | activeModal、短 toast queue | 审计日志、长错误详情 | 只保存 UI 必须渲染的短信息 |
| 乐观更新 | requestId、修改前快照 | 服务端最终事实 | 明确 rollback 与竞争规则 |
Masa 在改管理后台时踩过的坑,是把“使用 Zustand”当成目标。第一版把搜索输入、URL query、表格数据、行选择、toast 都放在一起。跳转页面后,旧筛选和旧选中项还在影响新视图。真正的修复不是增加 middleware,而是先列出哪些状态可以安全地全局保留。
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、modal、toast。真实项目中可以拆分文件,但先让 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);
这里的重点是:store 保存的是认证相关的 UI 状态,而不是认证秘密。access token、邮箱、地址、真实姓名、付款 session 都不应该放在这个 UI store 里。让 Claude Code 增加 persist 时,要明确说明 PII(可识别个人的信息)不能保存。
用 selector 控制渲染范围
Zustand hook 很容易使用,但也很容易订阅过多状态。组件如果直接调用 useCommerceUiStore(),就会订阅整个 store。结果是 toast 增加一条,购物车 badge 也重新渲染;筛选器变化,modal 也跟着更新。selector 是从 store 中只取所需字段的函数。
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 返回对象,要使用 useShallow,或者拆成多个 primitive selector。这个规则在小 demo 中看不明显,但在真实后台中,表格、侧栏、通知、筛选器一起更新时差异很大。
persist 只保存安全字段
persist middleware 可以让状态在刷新后保留,但它也是常见隐私事故来源。购物车行和筛选器可以保存;modal、toast、登录弹窗、requestId、token、个人信息不应该保存。下面用 partialize 限定保存范围,并在 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,hydration 不一致也要提前处理。服务器端无法读取 localStorage,浏览器端却可能恢复购物车数量或筛选器。购物车 badge、主题、筛选器摘要等依赖浏览器持久化的内容,可以在 mounted 后显示,或从 SSR 关键区域中拆出去。
异步 action 与乐观更新
乐观更新是在服务端确认前先改变界面。它适合关注、点赞、低风险购物车数量变化。风险是异步竞争:旧请求可能晚于新请求返回,然后覆盖新状态。store 至少要保存 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 的指示要写业务规则,而不是只写 API 路径。是否允许连续点击、失败时回滚什么、是否弹 toast、后发请求是否覆盖先发请求,都要明确。否则生成的 async/await 代码可能只覆盖 happy path。
用 Vitest 测试 store
Zustand action 可以不渲染 React 组件就测试。关键是每个测试前恢复初始状态,然后验证行为和设计约束。
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 再审查一次,但不要只写“review this”。要固定审查角度,要求它输出阻塞问题、补丁建议和缺失测试。
请只审查这个 diff 中的 Zustand 状态管理设计。
检查:
1. 本该留在 component local state 的值是否被移进 global store
2. React 组件是否使用 selector,而不是订阅整个 store
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 泄漏、乐观更新没有回滚等问题。
常见落坑
第一,把所有东西都 global 化。小表单输入、hover、只在一个组件树内使用的 popover 不需要 Zustand。第二,selector 不足,导致无关更新触发大面积重新渲染。第三,persist 泄露 PII,localStorage 中不应出现 token、邮箱、地址、公司名、咨询内容、支付 session。
第四,SSR/hydration 不一致。服务器端没有浏览器 storage,浏览器恢复后的购物车数量、主题、筛选器可能不同。第五,异步 action 竞争。慢网、连点、切换 tab 都可能让旧响应覆盖新状态。第六,滥用 devtools 或 immer。middleware 有价值,但每一个都应该有明确理由。
Claude Code Lab 咨询与验证
Claude Code Lab 的培训与导入咨询可以帮助团队审查现有 React 应用,判断哪些状态进 Zustand、哪些留给 TanStack Query、哪些字段可以 persist,并把这些判断写成 Claude Code review prompt。对于管理后台和电商流程,这比单纯生成一个 store 更有价值。
截至 2026 年 6 月 2 日,本文根据 Zustand 官方文档确认了 create、persist、useShallow 与 testing 的用法,并把示例整理为可复制的 TypeScript/TSX。实际项目中,先写“允许持久化字段表”的团队,后续清理 localStorage PII 和修 hydration 问题的时间明显更少。
免费 PDF: Claude Code 速查表
输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。
我们会妥善保护你的信息,不发送垃圾邮件。
把 Claude Code 变成真正能带来结果的工作流
先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。
关于作者
Masa
专注 Claude Code 实务流程、团队导入和内容转化的工程师。
相关文章
从Obsidian到CLAUDE.md的Claude Code流程:不再反复解释上下文
把 Obsidian 工作笔记整理成 CLAUDE.md 运行说明,让 Claude Code 每次都带着正确上下文开始。
Claude Code 收入 CTA 路由:从文章分流到 PDF、Gumroad 与咨询
用 Claude Code 按读者意图把文章流量分到免费 PDF、Gumroad 教材或咨询入口。
Claude Code 团队交接规则: 把审查证据、权限、回滚和收入路径一起交付
面向团队的 Claude Code 交接格式: 证据、权限、回滚、免费 PDF、Gumroad 与咨询路径都要可审查。