use-zustand
直接使用此技能处理 Zustand 状态。配合 use-tanstack-query 使用,将客户端状态和服务端状态交由各自合适的管理器负责。
同时,对相关文件应用 enforce-code-quality 和 enforce-typescript-strict 规则。
在 React 项目中编写或审查 Zustand store 时,请始终遵循以下规则。
1. Zustand 仅用于客户端状态——不要与 TanStack Query 职责重叠
Zustand 负责 UI/客户端状态:侧边栏开关、多步骤表单数据、主题、用户正在编辑的购物车内容。服务端数据(任何从 API 获取的数据)应属于 TanStack Query,不要重复存储到 Zustand store 中——参见 use-tanstack-query。混用两者的职责(例如将获取的数据复制到 Zustand 中“以便更轻松地访问”)会导致两个数据源,并可能因不同步而产生偏差。
2. 无需 Provider,但仅限客户端
Zustand store 是一个模块级单例——无需上下文包装器,并且可以通过导入 hook 在任何地方访问。但这意味着它依赖浏览器 API 和每个客户端的独立状态,因此:
- 只在客户端组件中初始化/使用 Zustand store(在 Next.js 中标记为
'use client')。 - 绝不要在服务端组件中创建或读取 Zustand store——因为没有像新
QueryClient那样“按请求隔离”的机制,这样做可能会导致状态在用户/请求之间泄露。
3. 使用切片模式组织大型 Store,而非一个扁平的大对象
将所有不相关的关注点(用户、购物车、UI 主题、通知)堆砌在一个 store 中,会变得难以导航和推理。按领域拆分为切片,然后将它们组合到一次 create() 调用中:
// userSlice.ts
export interface UserSlice {
user: User | null;
setUser: (user: User) => void;
logout: () => void;
}
export const createUserSlice: StateCreator<UserSlice> = (set) => ({
user: null,
setUser: (user) => set({ user }),
logout: () => set({ user: null }),
});
// cartSlice.ts
export interface CartSlice {
items: CartItem[];
addItem: (item: CartItem) => void;
}
export const createCartSlice: StateCreator<CartSlice> = (set) => ({
items: [],
addItem: (item) => set((state) => ({ items: [...state.items, item] })),
});
// store.ts
type AppState = UserSlice & CartSlice;
export const useAppStore = create<AppState>()((...a) => ({
...createUserSlice(...a),
...createCartSlice(...a),
}));
仅当两个领域真正独立且永远不会交互时(例如一个认证 store 与一个仅限分析的 store),才考虑使用真正的独立 store(而非切片)——多个 store 会带来协调开销,因此应优先使用一个 store 内的切片。
4. 使用具名的 action 方法,而不是在组件中直接调用 set()
将 action 暴露为 store 上的具名函数;不要在组件中直接调用 store 的 set。
// 避免:可行,但不语义化——组件需要了解状态结构
const set = useAppStore((state) => state.set);
set({ theme: "dark" });
// 推荐:具名、自文档化的 action
const setTheme = useAppStore((state) => state.setTheme);
setTheme("dark");
在切片自身的 action 内部使用 get() 读取其他状态或调用其他切片的 action(例如清除购物车的 logout action)——但注意防止切片之间的循环依赖。
5. 始终选择你需要的具体状态切片——绝不要取整个 store
// 避免:任何 store 变化都会导致重渲染,即使是不相关的字段
const state = useAppStore();
// 推荐:仅在 `user` 变化时重渲染
const user = useAppStore((state) => state.user);
6. 选择多个值或对象时使用 useShallow
从 store 中选择一个新构造的对象或数组(例如 { user, theme })会在每次渲染时创建新的引用,即使底层值没有变化,从而导致不必要的重新渲染。将其包裹在 useShallow 中,Zustand 将浅比较所选值而非引用比较:
import { useShallow } from "zustand/react/shallow";
const { user, theme } = useAppStore(
useShallow((state) => ({ user: state.user, theme: state.theme }))
);
7. 使用 immer 中间件处理嵌套状态更新
手动展开多层嵌套状态来更新一个字段容易出错且冗长。immer 中间件允许你编写直接的“可变”语法,实际上在底层以不可变方式应用:
import { immer } from "zustand/middleware/immer";
const useStore = create<State>()(
immer((set) => ({
nested: { deeply: { value: 0 } },
increment: () =>
set((state) => {
state.nested.deeply.value += 1; // 看起来可变,实际上不可变
}),
}))
);
对于极高频率的更新(例如 60fps 动画状态),可跳过 immer,因为它的开销可能成为问题;对于典型的 CRUD 风格状态,immer 带来的可读性提升值得小幅性能开销。
8. 使用柯里化形式 create<State>()(...) 对 Store 进行类型化
由于状态泛型是不变的,普通的 create((set) => ({...})) 对于非简单 store 无法被 TypeScript 可靠推断。始终使用柯里化形式:
interface CounterState {
count: number;
increment: () => void;
}
const useCounterStore = create<CounterState>()((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}));
9. 不要存储派生/计算值——在选择器或组件中计算
如果某个值可以从 store 中已有的其他状态计算得出(例如从项目列表中计算总数、从状态字段计算布尔值),就不要将其存储为单独字段——一旦某个更新路径忘记重新计算,它就会不同步。在选择器中计算,或在组件中作为普通派生值计算:
// 避免:独立的 `itemCount` 字段需要手动保持同步
addItem: (item) => set((state) => ({ items: [...state.items, item], itemCount: state.itemCount + 1 })),
// 推荐:在读取时派生
const itemCount = useCartStore((state) => state.items.length);
10. 对需要跨会话持久化的状态使用 persist,devtools 仅在开发环境中使用
- 为需要跨页面刷新或新会话持久化的状态(购物车内容、用户偏好)使用
persist中间件(基于localStorage/sessionStorage或自定义存储适配器)——而非每次访问都应重置的状态。 - 在
persist的选项中包含version,如果状态结构发生变化,还应包含migrate函数,这样旧的持久化状态不会在模式更改后静默破坏应用。 - 使用
devtools中间件集成 Redux DevTools 以便在开发期间检查状态变化,但确保不要在生产构建中启用。
export const useSettingsStore = create<SettingsState>()(
devtools(
persist(
(set) => ({ theme: "light", setTheme: (theme) => set({ theme }) }),
{ name: "settings-storage", version: 1 }
)
)
);
11. 使用 devtools 追踪状态流,并配合 React DevTools 捕捉不必要的重渲染
devtools 不仅仅是一个方便的日志工具——积极使用它来调试状态变化的原因,而不仅仅是“发生了什么”变化:
-
为每个 action 命名,以便 devtools 时间线显示有意义的轨迹,而不是每个更新的匿名
set条目。向set传递第三个参数作为 action 名称/类型:addItem: (item) => set( (state) => ({ items: [...state.items, item] }), false, "cart/addItem" // 在 Redux DevTools 的 action 列表中以此标签显示 ), -
为每个 store 命名(通过
devtools(..., { name: "cart-store" })),当项目有多个 store 时,这样 devtools 面板不会同时显示来自所有 store 的模糊更新混合。 -
当 bug 看起来像竞态条件或更新顺序问题时,使用 devtools 时间线确认实际的状态流顺序——逐步查看 action 列表,精确了解什么发生了变化、何时变化、以及前后的状态是什么,而不是通过散布在 action 代码中的
console.log来猜测。 -
诊断不必要的重渲染是另一个单独的工具,不是
devtools本身能显示的。 使用 React DevTools Profiler(或在开发环境中使用why-did-you-render,如果你需要更详细的调用点信息)来查看哪些组件在特定状态变化后重新渲染。如果某个组件在 store 更新中重新渲染,但该更新并未影响它选择的字段,那就说明你缺少一个选择器(规则 5)或useShallow包装(规则 6)——去修复选择器,而不是 store。
12. 使 Store 可测试:导出原始创建函数,而非仅绑定的 hook
同时导出 hook(供组件使用)和底层的 StateCreator/store 创建函数(供测试使用),这样测试可以为每个测试创建全新的隔离 store 实例,而不是在整个测试套件中共享模块级单例(这会导致状态在测试之间泄露)。
应用这些规则
- 新建 store:如果涉及多个领域,从第一天起就使用切片模式的单个 store;使用柯里化形式
create<State>()(...)进行类型化;仅当状态确实需要时才添加persist/devtools。 - 审查现有代码:标记缺少选择器函数的全 store 选择、组件中直接调用
set()而非具名 action、将派生状态存储为独立字段、将服务端获取的数据重复放入 Zustand 而非 TanStack Query、在服务端组件中使用 store、未命名的 action/store 导致 devtools 时间线难以阅读,以及任何因无关 store 变化而重渲染的组件(缺少选择器或useShallow)。