Skip to content
常见陷阱与最佳实践

常见陷阱与最佳实践

本章汇总 Zustand 开发中最常见的陷阱和最佳实践,每个陷阱附有错误示例和正确做法。


陷阱速查表

陷阱 严重度 章节
选择器返回新对象/数组导致无限重渲染 🔴 高 §1
set 中直接修改旧引用 🔴 高 §2
解构整个 Store(无选择器) 🟡 中 §3
闭包中读取过时的 state 🟡 中 §4
persist hydration 异步导致 UI 闪烁 🟡 中 §5
忘记创建新 Map/Set 实例 🟡 中 §6
巨型单一 Store 膨胀 🟢 低 §7
滥用 subscribe(组件中) 🟢 低 §8
create<T>()(...) 遗漏双重括号 🟢 低 §9
v4 → v5 导入路径变化 🟢 低 §10

§1 选择器返回新对象/数组导致无限重渲染

// ❌ 每次选择器执行都创建新对象 → Object.is 永远 false → 无限重渲染
const { bears, fishes } = useStore((s) => ({
  bears: s.bears,
  fishes: s.fishes,
}));

// ❌ 每次返回新数组
const [bears, fishes] = useStore((s) => [s.bears, s.fishes]);

// ✅ 方式 1:useShallow
import { useShallow } from 'zustand/react/shallow';
const { bears, fishes } = useStore(
  useShallow((s) => ({ bears: s.bears, fishes: s.fishes }))
);

// ✅ 方式 2:分别订阅
const bears = useStore((s) => s.bears);
const fishes = useStore((s) => s.fishes);

// ✅ 方式 3:选择器返回原始值
const total = useStore((s) => s.bears + s.fishes);  // number 是原始值

💡 最佳实践:默认让选择器返回原始值(number、string、boolean)。需要同时取多个字段时,用 useShallow 包裹。


§2 set 中直接修改旧引用

// ❌ 直接修改数组 → 引用不变 → 不触发重渲染
set((state) => {
  state.todos.push(newTodo);
  return state;           // 返回同一个引用!
});

// ❌ 直接修改对象属性
set((state) => {
  state.user.name = 'NewName';
  return { user: state.user };  // user 引用没变!
});

// ✅ 创建新引用
set((state) => ({
  todos: [...state.todos, newTodo],
  user: { ...state.user, name: 'NewName' },
}));

// ✅ 使用 Immer 中间件
import { immer } from 'zustand/middleware/immer';
create(
  immer((set) => ({
    todos: [],
    addTodo: (text) =>
      set((s) => {
        s.todos.push({ text, done: false }); // Immer 下安全
      }),
  }))
);

💡 最佳实践:有深层嵌套状态时用 Immer。状态结构扁平时手动展开即可。


§3 解构整个 Store(无选择器)

// ❌ 订阅整个 Store → 任何字段变化都重渲染
function Component() {
  const { bears, fishes } = useStore();
  // ...
}

// ✅ 用选择器精确订阅
function Component() {
  const bears = useStore((s) => s.bears);
  const fishes = useStore((s) => s.fishes);
  // bears 变化时才重渲染,fishes 变化不会触发
}

// ✅ 如果需要多个字段,用 useShallow
function Component() {
  const { bears, fishes } = useStore(
    useShallow((s) => ({ bears: s.bears, fishes: s.fishes }))
  );
}

§4 闭包中读取过时的 state

// ❌ 在组件闭包中捕获 state → 得到的是渲染时刻的快照
function Component() {
  const bears = useStore((s) => s.bears);
  const increase = useStore((s) => s.increase);

  const handleClick = () => {
    // bears 是闭包中的旧值
    if (bears < 10) increase();
  };
}

// ✅ 方式 1:在 action 中用 get() 或 set 回调
const useStore = create((set, get) => ({
  bears: 0,
  increaseIfBelow10: () => {
    if (get().bears < 10) {             // get() 永远是最新的
      set((s) => ({ bears: s.bears + 1 }));
    }
  },
}));

// ✅ 方式 2:在 action 中用 set 的函数式更新
const useStore = create((set) => ({
  bears: 0,
  increaseIfBelow10: () =>
    set((s) => (s.bears < 10 ? { bears: s.bears + 1 } : {})),
}));

💡 最佳实践:把依赖最新状态的逻辑放在Store 的 action 内部(用 get()set() 的回调参数),而不是在组件闭包中处理。


§5 persist hydration 异步导致 UI 闪烁

// ❌ 问题:首次渲染时 Store 还是初始值,hydration 完成后才更新
// 用户会看到从初始值到持久化值的闪烁

// ✅ 方案 1:skipHydration + 手动控制
const useStore = create(
  persist(
    (set) => ({ theme: 'light', setTheme: (t) => set({ theme: t }) }),
    { name: 'settings', skipHydration: true }
  )
);

function App() {
  const [ready, setReady] = useState(false);

  useEffect(() => {
    // 手动触发 hydration
    useStore.persist.rehydrate();
    // 等待完成
    const unsub = useStore.persist.onFinishHydration(() => setReady(true));
    return unsub;
  }, []);

  if (!ready) return <LoadingScreen />;  // hydration 完成后再渲染
  return <MainApp />;
}

// ✅ 方案 2:在 onRehydrateStorage 回调中设置 ready
function useHydrated() {
  const [hydrated, setHydrated] = useState(useStore.persist.hasHydrated());
  useEffect(() => {
    const unsub = useStore.persist.onFinishHydration(() => setHydrated(true));
    return unsub;
  }, []);
  return hydrated;
}

§6 忘记创建新 Map/Set 实例

// ❌ 直接修改 Map → 引用不变 → 不触发更新
const useStore = create((set) => ({
  users: new Map(),
  addUser: (user) =>
    set((s) => {
      s.users.set(user.id, user);    // 修改了同一个 Map!
      return { users: s.users };     // 返回旧引用 → 不更新
    }),
}));

// ✅ 创建新 Map
addUser: (user) =>
  set((s) => {
    const next = new Map(s.users);
    next.set(user.id, user);
    return { users: next };
  }),

// ✅ 或用 Immer
import { immer } from 'zustand/middleware/immer';
create(
  immer((set) => ({
    users: new Map(),
    addUser: (user) => set((s) => { s.users.set(user.id, user); }),
  }))
);

§7 巨型单一 Store 膨胀

// ❌ 所有状态挤在一个 Store 里
const useAppStore = create((set) => ({
  // 用户相关
  user: null, login: () => {}, logout: () => {},
  // 购物车相关
  cart: [], addToCart: () => {}, removeFromCart: () => {},
  // UI 相关
  theme: 'light', sidebarOpen: false, toggleSidebar: () => {},
  // 20+ 个字段和 action...
}));

// ✅ 按功能域拆分多个 Store
// stores/useUserStore.ts
export const useUserStore = create((set) => ({
  user: null, login: () => {}, logout: () => {},
}));

// stores/useCartStore.ts
export const useCartStore = create((set, get) => ({
  items: [], add: () => {}, total: () => {},
}));

// stores/useUIStore.ts
export const useUIStore = create((set) => ({
  theme: 'light', sidebarOpen: false, toggleSidebar: () => {},
}));

💡 最佳实践:Zustand 推崇多 Store 按功能域拆分。一个 Store 不应超过 10 个字段(含 action)。超大 Store 也会让选择器粒度变粗,失去性能优势。


§8 在组件中用 subscribe 替代选择器

// ❌ 在组件中用 subscribe 同步状态 → 手动管理订阅,容易遗漏清理
function Component() {
  const [bears, setBears] = useState(0);
  useEffect(() => {
    const unsub = useStore.subscribe((s) => setBears(s.bears));
    return unsub;
  }, []);
}

// ✅ 直接用选择器 → Zustand 自动管理订阅和清理
function Component() {
  const bears = useStore((s) => s.bears);
}

💡 最佳实践subscribe 用于组件外部(日志、副作用、非 React 环境等)。组件内永远使用选择器 hook。


§9 create<T>()(...) 遗漏双重括号

// ❌ 缺少第一对括号(泛型实例化)
const useStore = create<BearState>(
  persist((set) => ({ ... }))   // TypeScript 报错
);

// ❌ 或不写类型
const useStore = create(
  persist((set) => ({ ... }))   // state 类型为 unknown
);

// ✅ 正确的双重括号写法
const useStore = create<BearState>()(
  persist((set) => ({ ... }))
);

🚨 陷阱create<T>()(...) 是 TypeScript 的显式泛型实例化语法。第一层 () 实例化泛型,第二层 (...) 传入 creator 函数。不写双重括号会导致类型推断失败。


§10 v4 → v5 导入路径变化

// ❌ v4 默认导入(v5 中不存在)
import create from 'zustand';

// ✅ v5 命名导入
import { create } from 'zustand';

// ❌ v4 从主入口导入 shallow
import { shallow } from 'zustand';

// ✅ v5 从子路径导入
import { shallow } from 'zustand/shallow';

// ❌ v4 从主入口导入 persist/devtools
import { persist, devtools } from 'zustand';

// ✅ v5 从 middleware 路径导入
import { persist, devtools } from 'zustand/middleware';

最佳实践清单

Store 设计

  • 按功能域拆分多个独立 Store(每个 Store ≤ 10 个字段)
  • Store 文件名以 use 开头(useXxxStore
  • TypeScript 类型使用 create<T>()(...) 双重括号语法
  • 提供一个 reset() action 用于测试和状态重置

选择器

  • 选择器默认返回原始值(number、string、boolean)
  • 多字段组合使用 useShallow 包裹
  • 不直接解构整个 Store:const { a, b } = useStore()
  • 提取可复用的选择器为独立函数(原子选择器)

Action

  • 依赖前值的更新使用 set((s) => ({ ... }))get()
  • 异步操作正确处理 loading / error 状态
  • 异步操作处理竞态条件(AbortController 或 requestId)
  • 乐观更新必须有回滚逻辑

不可变更新

  • 深层嵌套状态使用 Immer 中间件
  • Map/Set 操作创建新实例(或使用 Immer)
  • set 中不要直接修改旧 state 引用

中间件

  • 开发环境启用 devtools 中间件
  • 需要持久化的用户偏好使用 persist 中间件
  • persist 注意异步 hydration 导致的 UI 闪烁
  • Immer 放在中间件组合的最内层

性能

  • 定期检查是否有组件订阅了整个 Store
  • 使用 React DevTools Profiler 确认重渲染范围

测试

  • 每个测试前用 setState(initialState, true) 重置 Store
  • 测试异步 action 时 mock fetch
  • 单元测试 Store 本身(不依赖 React 渲染)

生产检查清单

  • 所有 Store 都有明确的 TypeScript 类型
  • 选择器精确订阅最小状态片段
  • 异步操作有完整的 loading/error 处理
  • 异步操作有竞态保护
  • persist 中间件的 hydration 状态已处理
  • reset() action 已实现(方便测试和登出场景)
  • 没有组件中使用 subscribe 替代选择器
  • devtools 在生产环境已禁用
  • Store 已按功能域拆分,没有巨型 Store
  • 确认运行的是 Zustand v5(或明确使用 v4)