Use Cases (更新: 2026/6/2)

用 Claude Code 设计 Zustand 状态管理

用 Claude Code 设计 Zustand store、selector、persist、异步 action、测试与审查提示。

用 Claude Code 设计 Zustand 状态管理

先决定状态边界

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/toastactiveModal、短 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 官方文档确认了 createpersistuseShallow 与 testing 的用法,并把示例整理为可复制的 TypeScript/TSX。实际项目中,先写“允许持久化字段表”的团队,后续清理 localStorage PII 和修 hydration 问题的时间明显更少。

#Claude Code #Zustand #React #state management #TypeScript
免费

免费 PDF: Claude Code 速查表

输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。

我们会妥善保护你的信息,不发送垃圾邮件。

把 Claude Code 变成真正能带来结果的工作流

先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。

Masa

关于作者

Masa

专注 Claude Code 实务流程、团队导入和内容转化的工程师。