前端状态管理:Zustand,一个轻量的全局状态方案

Zustand 这几年在 React 生态里越来越常见,原因很简单:API 小、心智负担低、和 Hooks 契合得比较自然。
如果项目里不想上一个「全家桶」式的状态管理库,但又希望有个靠谱的全局状态方案,可以把它当成首选之一。

Zustand 的核心只有两件事:

  • create 定义一个 store;
  • 在组件里用返回的 hook 读写状态。

示例:

 1import { create } from 'zustand';
 2
 3type BearState = {
 4  count: number;
 5  increase: () => void;
 6};
 7
 8const useBearStore = create<BearState>((set) => ({
 9  count: 0,
10  increase: () => set((state) => ({ count: state.count + 1 })),
11}));
12
13function BearCounter() {
14  const count = useBearStore((state) => state.count);
15  const increase = useBearStore((state) => state.increase);
16
17  return (
18    <div>
19      <span>{count}</span>
20      <button onClick={increase}>+1</button>
21    </div>
22  );
23}

几个点:

  • store 不是通过 <Provider> 注入,而是一个普通的 hook,随用随引;
  • set 支持传对象或函数,函数里可以拿到前一个 state;
  • 组件里按「选择器」拿自己关心的那一小块状态。

Zustand 鼓励用选择器精确订阅状态的一部分:

1const count = useBearStore((state) => state.count);

默认情况下,Zustand 会用 Object.is 比较选择器的返回结果,只有变化时才触发重渲染。
如果需要更细粒度控制,可以配合 shallow

1import { shallow } from 'zustand/shallow';
2
3const { x, y } = useStore(
4  (state) => ({ x: state.x, y: state.y }),
5  shallow
6);

这在订阅多个字段但又希望避免「任一字段变化就导致整块对象被认为不同」的时候比较有用。

项目一大,一锅端的 store 很快就会变得难以维护。
常见的做法是按领域拆成多个 slice,再在 create 时组合起来:

 1type UserSlice = {
 2  user: { id: string; name: string } | null;
 3  setUser: (user: UserSlice['user']) => void;
 4};
 5
 6type TodoSlice = {
 7  todos: string[];
 8  addTodo: (text: string) => void;
 9};
10
11type StoreState = UserSlice & TodoSlice;
12
13const createUserSlice = (set: any): UserSlice => ({
14  user: null,
15  setUser: (user) => set({ user }),
16});
17
18const createTodoSlice = (set: any): TodoSlice => ({
19  todos: [],
20  addTodo: (text) => set((state: StoreState) => ({
21    todos: [...state.todos, text],
22  })),
23});
24
25const useStore = create<StoreState>((set) => ({
26  ...createUserSlice(set),
27  ...createTodoSlice(set),
28}));

这样每个 slice 专注一块领域逻辑,需要时在组件中用同一个 useStore hook 通过选择器拿对应部分。

Zustand 的「中间件」是一大卖点,用来给 store 增加能力而不改动核心逻辑。
比较常见的几个:

  • persist:把状态持久化到 localStorage 等;
  • devtools:接入 Redux DevTools;
  • immer:用 immer 的方式写状态更新。

以持久化为例:

 1import { create } from 'zustand';
 2import { persist } from 'zustand/middleware';
 3
 4type SettingsState = {
 5  theme: 'light' | 'dark';
 6  setTheme: (theme: SettingsState['theme']) => void;
 7};
 8
 9const useSettingsStore = create<SettingsState>()(
10  persist(
11    (set) => ({
12      theme: 'light',
13      setTheme: (theme) => set({ theme }),
14    }),
15    {
16      name: 'app-settings', // localStorage key
17    }
18  )
19);

只在意状态本身的逻辑可以保持很干净,持久化细节交给中间件处理。

Zustand 本身不关心异步,你可以在 action 里直接写 async,也可以把异步放在外部逻辑里:

 1type UserState = {
 2  user: { id: string; name: string } | null;
 3  loading: boolean;
 4  error: string | null;
 5  fetchUser: (id: string) => Promise<void>;
 6};
 7
 8const useUserStore = create<UserState>((set) => ({
 9  user: null,
10  loading: false,
11  error: null,
12  async fetchUser(id) {
13    set({ loading: true, error: null });
14    try {
15      const res = await fetch(`/api/users/${id}`);
16      const data = await res.json();
17      set({ user: data, loading: false });
18    } catch (e: any) {
19      set({ error: e?.message ?? '请求失败', loading: false });
20    }
21  },
22}));

组件里只关心状态形态,不需要知道请求的细节:

1const { user, loading, error, fetchUser } = useUserStore((state) => state);

如果项目已经用了 React Query / SWR 这类数据请求库,Zustand 更适合作为「UI 状态 / 跨组件控制状态」的容器,例如当前选中项、侧边栏开关、临时过滤条件等。

简单对比一下几个常见选择:

  • React Context + useReducer

    • 优点:不引入额外依赖,类型推断友好;
    • 缺点:一旦 state 变大、更新频繁,很容易出现整棵子树重渲染的问题,需要额外拆分优化。
  • Redux / Redux Toolkit

    • 优点:生态成熟、调试工具完善、约束明确;
    • 缺点:心智模型相对重,不是所有项目都需要整套架子。
  • Zustand

    • 优点:体积小、API 简洁、默认就支持选择器和细粒度订阅;
    • 缺点:约束相对少,项目大了之后需要团队约定好基本用法(如何拆 slice、命名、在哪里写异步等)。

对于中小体量的应用,或者前后端都由一两个核心开发维护的项目,Zustand 的成本和收益比通常是比较合适的。

  • 明确好「哪些状态放在 Zustand」「哪些直接用组件内 state」,避免什么都往全局里堆;
  • store 拆分按领域来,而不是按页面来,这样更利于复用;
  • 给 store 的 action 起清晰的名字,和领域语言贴近,而不是一堆 setXxx
  • 搭配 TypeScript,尽量把 store 类型定义成独立的 State / Action 接口,出问题时更容易排查。

整体来说,Zustand 更像是一块可塑性比较强的乐高砖块:
愿意做一点结构设计,就能搭出一套干净的状态层;不想上太重方案时,它也足够轻。