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

Zustand 状态管理不变重:selector 与 persist 的关键设计

用可运行的 TypeScript 讲清 Zustand:store 边界、用 selector 减少重渲染、persist 安全持久化、异步乐观更新。

Zustand 状态管理不变重:selector 与 persist 的关键设计

我让 Claude Code「用 Zustand 做状态管理」,五分钟就生成了一个 store。

我觉得真方便,就把它塞进后台管理页:筛选器、购物车、登录状态,全都丢进去。一开始用得很爽。出问题是在我给页面加了一个通知红点之后——每弹出一条 toast,整张毫不相关的表格就重新渲染一次,滚动开始卡顿。

罪魁祸首不是 Zustand。是我没想清楚「放什么进去」和「怎么读出来」,直接拿生成的代码就用了。Zustand 轻是它的优点,但你在设计上偷的懒,会原封不动地变成卡顿。

这篇文章里,我把自己为了不再栽第二次而固化下来的做法,连同可以直接复制运行的 TypeScript 一起放在这里。

本文要点

  • 放进 Zustand 的,只该是「跨远距离组件共享的值」和「URL 表达不了的 UI 状态」。从服务端拉来的列表需要缓存管理,要单独处理。
  • 页面变卡的元凶大多是 selector 不够。不要订阅整个 store,只挑需要的值;一次返回多个值时用 useShallow 做浅比较。
  • persist 很好用,但不用 partialize 限定保存范围,个人信息就会残留在 localStorage 里。能存的只有匿名的 UI 配置。
  • 异步乐观更新必须把 requestId 和「回滚值」成对持有,否则旧响应会覆盖掉新状态。
  • 在 Redux 和 Context 之间犹豫时,按「样板代码的量」和「重渲染控制」二选一来决定。Zustand 是想把两边都做得轻巧时的选项。

Zustand 到底是干什么的

Zustand 是一个面向 React 的状态管理库。所谓状态管理,指的是把想跨页面共享的值,以及修改这些值的逻辑,集中到一个地方整理好。

举个例子:页头的购物车红点,和另一个页面的购物车页面。这两个都想看同一份「购物车内容」,但在组件树里它们隔得很远。用 props 一层层往下传太痛苦;可放进全局变量,值变了页面又不会重新渲染。

于是,做一个装共享值的小盒子,只有订阅了这个盒子的组件,才会随着值的变化自动重新渲染。这个盒子就是 store。Zustand 的卖点是,几行代码就能造出这个盒子,而且不用引入 useContext 那种笨重的机制。官方用法整理在 Zustand 官方文档里。

不过「轻」是有代价的。它不像 Redux 那样强制你「必须这么写」,约束很松——所以你不自己定下设计,就会像我那样什么都往盒子里塞,最后陷进去。所以第一步,先划边界。

哪些状态放进 store,哪些不放

我现在用的判断标准很简单:「多个隔得远的组件用同一个值」「有 URL 难以表达的 UI 状态」「想把更新逻辑集中到一处测试」。只有命中这几条之一的,才放进 store。

反过来,从服务端拉来的商品列表、用户列表,不放进去。这类数据需要缓存、重新请求、过期判断(stale),自己用 store 扛着这些会很惨。服务端来的数据交给专门的工具,Zustand 里只放页面自己的需求(UI 状态)。这条线是后面一切的地基。

场景放进 store 的值不放的值给 Claude Code 的说明
后台筛选器keyword、status、page、pageSize完整 API 响应区分 URL 同步项和纯 UI 项
购物车SKU、数量、展示用合计支付 session、真实库存限定写入 localStorage 的字段
认证 UI 状态登录弹窗开关、checking 提示access token、邮箱、地址个人信息不进 store 也不进 persist
Modal/toast当前打开的 modal、toast 队列长错误日志、审计日志只保留要显示的短文案
乐观更新处理中的 requestId、修改前快照服务端的最终事实指定失败回滚值和竞争规则

只要一开始先把这张表写出来,后面「localStorage 里怎么有个人信息」吓出一身冷汗的次数就大幅减少了。状态管理的整体思路整理在用 Claude Code 设计状态管理的指南里,React 这一侧的推进方式在用 Claude Code 推进 React 开发的指南里,想从地基理清的人可以一并看看。

用 TypeScript 定义 store

来看实物。下面把后台筛选器、购物车、认证 UI、modal、toast 放进同一个 store,给出一个最小的完整版。实战里拆成多个文件也行,但让 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,
};

// 兼容没有 crypto.randomUUID 的环境,准备一个降级方案
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: [],

  // 改了筛选条件就一定把页码退回第 1 页(避免旧页码导致空列表)
  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),
    })),

  // 数量降到 0 以下的行自动移除
  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、邮箱、地址放进 store,它们就变成了页面到处都能读到的状态。请一开始就对 Claude Code 把话说死:「PII,也就是能识别到个人的信息,不要放进 store。」光这一句,后续的善后就能少很多。

用 selector 收紧重渲染范围

开头我闯的祸——「一条 toast 让整页卡顿」——根源就在这里。

Zustand 的 store 是一个 hook,能从组件里直接读。但你写 useCommerceUiStore() 订阅整个 store,那么 store 里任何一个值变化,这个组件都会重新渲染。toast 只是多了一条,购物车红点、筛选栏全被牵连进来。

阻止这件事的就是 selector。selector 是「从 store 里只挑出自己真正需要的那部分的函数」。只选一个值时直接传进去就行;要把多个值打包成对象返回时,套一层 useShallowuseShallow 会做浅比较,内容相同就跳过重渲染。

import { useShallow } from "zustand/react/shallow";
import {
  useCommerceUiStore,
  type CommerceUiState,
} from "./commerce-ui-store";

// 只选购物车的总件数,cart 以外的值变化都不会触发重渲染
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() {
  // 一次取多个值时,用 useShallow 做浅比较
  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>
  );
}

还有一个坑:如果在 selector 里每次都新建数组或对象,即便用了 useShallow,也会被判定为「每次都是不同的引用」,重渲染照样停不下来。useShallow 是浅比较,它看的是里面的元素是不是同一个引用。所以基本原则是「老老实实挑出需要的值返回」,整形、加工要么放到组件那一侧做,要么用 useShallow 包起来。UI 小的时候看不出这个差别,但在表格、侧栏、通知共处一页的后台里,有没有这一步,体感速度差别非常明显。

persist 用 partialize 限定保存范围

persist 中间件能让状态在刷新后保留,很方便。但不限定保存内容的 persist,就是个事故装置。

购物车和筛选器留着挺好。可要是把 modal 开关、toast、认证 UI 状态、处理中的 requestId 也一起存了,刷新后就会冒出莫名其妙打开的弹窗,或者已经关掉的 toast 又复活。更糟的是什么都不想就把整个 store 存下来,token 和个人信息以明文残留在 localStorage 里。

这时候用 partialize。它是「只抽出要保存的字段的函数」。下面的例子把保存范围收窄到 filterscart,并在服务端(SSR)没有 window 时返回一个什么都不做的安全 storage。各选项的说明在官方的 persist 中间件参考里。

import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import {
  createCommerceUiSlice,
  type CommerceUiState,
} from "./commerce-ui-store";

// 服务端(SSR)没有 window,返回一个什么都不做的 storage
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,
    ),
    // 能存的只有筛选器和购物车。token 和个人信息绝对不要包含进来
    partialize: (state): PersistedCommerceUiState => ({
      filters: state.filters,
      cart: state.cart,
    }),
  }),
);

在 Next.js 或 Astro 这类有 SSR 的环境里,还有一件事要小心。服务端生成的 HTML 当然读不到 localStorage,所以购物车是 0 件;而浏览器里从 localStorage 恢复成了 3 件——两边对不上。这就是 hydration(注水)不一致,会在控制台报警告,或者画面闪一下。像红点、合计金额这种依赖恢复值的显示,要么挂载后再显示,要么拆到服务端不渲染的区域。这一条也写进给 Claude Code 的指令里才稳妥。

异步与乐观更新:别输给旧响应

乐观更新(optimistic update)是不等服务端返回成功,先把界面改掉的手法。它适合关注、点赞、改购物车数量这类失败率低、又想减少等待的操作。

但这里有个陷阱。异步操作一旦发生竞争,先发出去的请求带着旧响应晚一步回来,可能覆盖掉之后才形成的新状态。连点、切换标签页、网速慢时都会这样。防住它的最低底线,是持有 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");
    }

    // 标识这次操作的 ID,稍后用来确认「自己是不是最新的那次」
    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 允不允许连点」「回滚后弹不弹 toast」。这里含糊,它会还给你一段长得很漂亮的 async/await,可一到真实用户操作,重复提交和旧响应轻轻松松就把它打败了。漂亮和正确是两回事。

和 Redux、Context 怎么分工

常有人问我:「用 Zustand 行不行,还是该上 Redux 或 Context?」我的分工是这样的。

Context API 用来分发主题、语言区域这类「几乎不变的值」是够用的。但把频繁变化的值放进 Context,读这个 Context 的组件就会一个接一个地重新渲染。想精细控制重渲染,Context 不合适。

Redux 的强项是用 action 和 reducer 把更新的来龙去脉严格地追踪下来。在多人大团队里想完整追溯「谁、怎么改了状态」,或者想用时间旅行调试,它至今仍是有力选项。代价是样板代码多。

Zustand 介于两者之间:把样板代码压到最小,同时又能用 selector 收紧重渲染。中小规模、想「轻一点,但速度上不打算妥协」时,它是首选。下面这张表是我的粗略标准。

维度Context APIRedux (Toolkit)Zustand
代码量
重渲染控制可用 selector用 selector,强
更新可追踪性高(历史、devtools)中(可用 devtools)
适合规模小(接近不变的值)中到大、多人小到中
学习成本

另外,从服务端拉数据这件事,无论选哪个都是另一码事。列表的获取、缓存、重新请求交给专门的库,Zustand 里只放页面的 UI 状态。有时你想要更细粒度的、类似 atom 的管理,那部分在Jotai atoms 的文章里另作展开。想从状态管理的全貌出发再做决定的人,先读状态管理设计指南会让这里的判断更快。

可复制运行:用 Vitest 测试 store

Zustand 的 store,不用渲染 React 就能测。这是轻量库一个不起眼的好处——它接近一个纯对象,所以能只对逻辑做高速验证。诀窍只有一个:每个测试前恢复到初始状态。忘了这一步,上一个测试的购物车就会残留下来,让你为莫名其妙的失败发愁。

下面的测试直接放进项目就能跑(commerce-ui-store.ts 就是上面那个 store 定义)。

import { beforeEach, describe, expect, it } from "vitest";
import { useCommerceUiStore } from "./commerce-ui-store";

// 把启动那一刻的初始状态先存下来
const initialState = useCommerceUiStore.getInitialState();

beforeEach(() => {
  // 每个测试前都把 store 彻底重置(第二个参数 true 表示整体替换)
  useCommerceUiStore.setState(initialState, true);
});

describe("commerce ui store", () => {
  it("同一商品加入两次,数量变为 2,并算出合计", () => {
    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("改动筛选条件时页码回到 1", () => {
    useCommerceUiStore.getState().setFilters({ page: 4 });
    useCommerceUiStore.getState().setFilters({ keyword: "refund" });

    expect(useCommerceUiStore.getState().filters).toMatchObject({
      keyword: "refund",
      page: 1,
    });
  });

  it("认证 UI 和 toast 按预期保留", () => {
    useCommerceUiStore.getState().setAuthStatus("signedIn");
    useCommerceUiStore.getState().pushToast({
      kind: "success",
      message: "Saved",
    });

    expect(useCommerceUiStore.getState().auth.status).toBe("signedIn");
    expect(useCommerceUiStore.getState().toasts).toHaveLength(1);
  });
});

测试的好处不只是验证「代码能不能跑」,还能把「设计的约定有没有被遵守」固定下来。改筛选页码回 1、数量为 0 时删行、乐观更新失败时回滚——把这些规格先写进测试名里,之后哪怕让 Claude Code 去改,行为也不会跑偏。实现和审查分成两条消息来委托;审查时把视角固定为「只批判性地看这个 diff 里的 Zustand 部分,指出不该进 store 的值、过宽的 selector、persist 的泄漏」,能压住自己给自己写的代码松松地点头的毛病。

常见问题

问:一个应用就一个 store,还是拆成多个? 答:小规模的话一个就够。功能多起来后,按关注点拆成购物车用、后台用,会更清晰。Zustand 可以建多个 store,没必要硬撑着维护一个巨无霸。

问:useShallow 什么时候需要? 答:selector 把多个值打包成对象或数组返回时需要。只返回单个值(数字或字符串)时不需要。返回多个却不加,就会被判成每次都是不同引用,重渲染停不下来。

问:想改 persist 数据的结构怎么办? 答:把 version 调高,用 migrate 转换旧数据。改 name(保存键)会被当成另一份东西、旧数据还留着,所以结构变了就用 version 显式迁移更安全。

问:从服务端拉的数据能放进 Zustand 吗? 答:基本不放。需要缓存和重新请求的服务端数据交给专门的库,Zustand 里只放页面的 UI 状态。混在一起,过期判断和加载管理就得自己扛。

问:从 Redux 迁到 Zustand 值不值? 答:动机是想减少样板代码、想让重渲染更轻,那就值得。反过来,如果依赖严格的更新历史或时间旅行调试,保留 Redux 也是合理的选择。

我实际试下来的结果

开头那次「一条 toast 让整页卡顿」之后,我第一件做的不是加新功能,而是在往 store 里放东西之前,先画一张表——把「能存的值」和「存了有风险的值」分开。

以那张表为起点,store 里只放 UI 状态,组件一律经 selector 读取,persistpartialize 收窄到只有 filterscart。把这些贯彻下去之后,再加通知,表格也安安静静;localStorage 里的个人信息也消失了。

这篇文章里的代码,是在 2026 年 6 月 7 日对照 Zustand 官方文档的 createpersistuseShallow 以及测试思路确认过、整理成可以直接复制的 TypeScript 形态的。在实际项目里几次试下来我的感受是:设计上的差距不体现在代码的短,而体现在「边界有多清晰」。开头那十分钟先画好表,比之后花半天清理 localStorage 要快得多。

想在团队里统一状态管理的判断标准,或者拿现有后台当题材做一次盘点,都可以在培训与导入咨询里一起理清。也欢迎到产品页看看现成的模板。先把上面的 store 和测试照样跑起来,再给自己的页面补上一张「不放进去的值」的清单,从这里开始就好。

#Claude Code #Zustand #React #状态管理 #TypeScript
免费

免费 PDF: Claude Code 速查表

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

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

让 Claude Code 真正进入可验证的工作流

先用免费 PDF 固定基础,再用 Gumroad 教材复用工作流;如果涉及团队导入、权限或收入路径,可以直接咨询。

Masa

关于作者

Masa

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