Zustand 状态管理不变重:selector 与 persist 的关键设计
用可运行的 TypeScript 讲清 Zustand:store 边界、用 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 里只挑出自己真正需要的那部分的函数」。只选一个值时直接传进去就行;要把多个值打包成对象返回时,套一层 useShallow。useShallow 会做浅比较,内容相同就跳过重渲染。
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。它是「只抽出要保存的字段的函数」。下面的例子把保存范围收窄到 filters 和 cart,并在服务端(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 API | Redux (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 读取,persist 用 partialize 收窄到只有 filters 和 cart。把这些贯彻下去之后,再加通知,表格也安安静静;localStorage 里的个人信息也消失了。
这篇文章里的代码,是在 2026 年 6 月 7 日对照 Zustand 官方文档的 create、persist、useShallow 以及测试思路确认过、整理成可以直接复制的 TypeScript 形态的。在实际项目里几次试下来我的感受是:设计上的差距不体现在代码的短,而体现在「边界有多清晰」。开头那十分钟先画好表,比之后花半天清理 localStorage 要快得多。
想在团队里统一状态管理的判断标准,或者拿现有后台当题材做一次盘点,都可以在培训与导入咨询里一起理清。也欢迎到产品页看看现成的模板。先把上面的 store 和测试照样跑起来,再给自己的页面补上一张「不放进去的值」的清单,从这里开始就好。
免费 PDF: Claude Code 速查表
输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。
我们会妥善保护你的信息,不发送垃圾邮件。
让 Claude Code 真正进入可验证的工作流
先用免费 PDF 固定基础,再用 Gumroad 教材复用工作流;如果涉及团队导入、权限或收入路径,可以直接咨询。
关于作者
Masa
专注 Claude Code 实务流程、团队导入和内容转化的工程师。