Communitygithub.com

C1131-G/skills

个人技能集合,基于我的知识和实践经验,观点明确,非官方文档,请谨慎使用,如有问题欢迎反馈。

skills 是什么?

该技能是 C1131-G 个人知识库与工作流中提炼出的一组开发实践规则,主要用于指导如何在 React 项目中正确使用 Zustand 进行客户端状态管理。它涵盖了 Zustand 的核心原则:与 TanStack Query 职责分离(客户端状态 vs 服务端状态)、避免 Provider 但仅限于客户端组件、使用切片模式组织大型 Store、通过命名 action 方法而非直接调用 set()、精确选择状态切片、使用 useShallow 防止不必要的重渲染、immer 中间件处理嵌套状态、类型安全的 curried create 写法、避免存储派生值、以及 persist 和 devtools 中间件的正确使用。技能还特别强调了 Store 的可测试性,建议导出原始创建函数以便在测试中创建隔离实例。该技能适用于任何基于 React 的项目,尤其是在 Next.js 等支持服务端组件环境中,能帮助开发者避免服务端组件误用 Zustand 导致的状态泄露问题。

兼容平台~Claude Code~Codex CLI~Cursor
npx skills add C1131-G/skills

在你喜欢的 AI 中提问

打开一个已预加载此 Agent Skill 的新对话。

文档

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)。

相关技能