Zustand 从入门到精通:我的工程实践笔记
目标读者:已经会用 Zustand,但想把它用得"稳"和"可演进"的 React 开发者
引言
我写这份笔记的目的很简单:把 Zustand 从"会用"推进到"用得稳、用得可演进"。 我不会在这里重复 API 文档式的讲解,而是用一个完整的 Todo Demo,把我认为最重要的工程实践串起来:订阅边界、actions/state 分离、persist 的坑、派生状态(computed)怎么做,以及为什么我尽量不用 useShallow。
Zustand 的 API 文档很简单,几行代码就能跑起来。但在真实项目中,如何组织 store?如何避免不必要的重渲染?持久化有哪些坑?派生状态怎么做?
我用一个完整的 Todo Demo,把这些问题串起来,分享一套在项目中验证过的最佳实践。
1. 我最终的 Demo(你可以直接跑)
这份代码是"我认为比较稳"的 Zustand 用法:
- 组件只订阅自己需要的数据
- actions/state 分离
- persist 只持久化纯数据(避免函数丢失)
- 派生值用 selector 直接订阅(类似 Vue computed 的效果)
- 表单用 react-hook-form + zod
0.1 useTodoStore.ts(Zustand store)
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
export interface Todo {
id: string;
text: string;
completed: boolean;
}
interface TodoState {
todos: Todo[];
}
interface TodoActions {
addTodo: (text: string) => void;
toggleTodo: (id: string) => void;
removeTodo: (id: string) => void;
clearCompleted: () => void;
}
type TodoStore = TodoState & { actions: TodoActions };
const useTodoStore = create<TodoStore>()(
persist(
immer(set => ({
todos: [],
actions: {
addTodo: (text: string) =>
set(state => {
state.todos.push({
id: crypto.randomUUID(),
text,
completed: false,
});
}),
toggleTodo: (id: string) =>
set(state => {
const todo = state.todos.find((todo: Todo) => todo.id === id);
if (todo) todo.completed = !todo.completed;
}),
removeTodo: (id: string) =>
set(state => {
state.todos = state.todos.filter((todo: Todo) => todo.id !== id);
}),
clearCompleted: () =>
set(state => {
state.todos = state.todos.filter((todo: Todo) => !todo.completed);
}),
},
})),
{
name: 'todos-storage',
/**
* 关键:persist 只持久化可序列化的数据。
* 我只存 todos,不存 actions(函数不能 JSON 序列化,否则会出现 addTodo is not a function)
*/
partialize: state => ({ todos: state.todos }),
}
)
);
/**
* 我不直接导出 useTodoStore(避免误订阅整个 store),
* 而是导出语义化 hooks,集中 selector 逻辑。
*/
export const useTodos = () => useTodoStore(s => s.todos);
export const useTodoActions = () => useTodoStore(s => s.actions);
/**
* 派生值:类似 Vue computed。
* 组件如果只关心 count,就只订阅这个 selector,而不是订阅 todos。
*/
export const useIncompleteCount = () =>
useTodoStore(s => s.todos.reduce((acc, t) => acc + (t.completed ? 0 : 1), 0));
0.2 schema.ts(zod schema)
import { z } from 'zod';
export const todoSchema = z.object({
todoText: z.string().trim().min(1, '请输入待办项'),
});
export type TodoValues = z.infer<typeof todoSchema>;
0.3 Demo.tsx(React 组件:AddTodo / TodoList / Footer)
import { Button } from '../../components/ui/button';
import clsx from 'clsx';
import {
useIncompleteCount,
useTodoActions,
useTodos,
} from '@/store/useTodoStore';
import { useForm } from 'react-hook-form';
import { todoSchema, type TodoValues } from './schema';
import { zodResolver } from '@hookform/resolvers/zod';
const AddTodo = () => {
const {
register,
handleSubmit,
reset,
formState: { errors },
} = useForm<TodoValues>({
resolver: zodResolver(todoSchema),
defaultValues: {
todoText: '',
},
});
const { addTodo } = useTodoActions();
const onSubmit = (values: TodoValues) => {
addTodo(values.todoText);
reset();
};
return (
<form
onSubmit={handleSubmit(onSubmit)}
className="my-2 flex justify-between"
>
<div className="flex min-h-15 flex-col">
<input
className="rounded-xl border border-gray-600 px-2 py-1 text-sm"
placeholder="请输入任务"
{...register('todoText')}
/>
{errors.todoText && (
<span className="mt-1 ml-1 text-xs text-red-500">
*{errors.todoText.message}
</span>
)}
</div>
<Button type="submit" variant="outline">
添加
</Button>
</form>
);
};
const TodoList = () => {
const todos = useTodos();
const { toggleTodo, removeTodo } = useTodoActions();
return (
<>
{todos.map(todo => (
<div key={todo.id} className="mt-2 flex justify-between">
<div>
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo(todo.id)}
/>
<span
className={clsx(
'ml-2',
todo.completed && 'text-gray-400 line-through'
)}
>
{todo.text}
</span>
</div>
<Button type="button" onClick={() => removeTodo(todo.id)}>
删除
</Button>
</div>
))}
</>
);
};
const Footer = () => {
const { clearCompleted } = useTodoActions();
const incompleteCount = useIncompleteCount();
return (
<>
<div className="my-2 flex justify-end">
<span>未完成数量:{incompleteCount}</span>
</div>
<Button type="button" onClick={clearCompleted} className="rounded-full">
删除已完成
</Button>
</>
);
};
const Demo = () => {
return (
<div className="mt-2 ml-2 flex w-100 flex-col rounded-xl bg-gray-100 px-3 py-2">
<h1 className="self-center font-bold">TODO LIST</h1>
<AddTodo />
<TodoList />
<Footer />
</div>
);
};
export default Demo;
2. 我如何理解 Zustand 的"进阶点"
2.1 订阅边界是第一性原理
Zustand 组件更新的触发条件,我用一句话描述:
我订阅的 selector 返回值发生变化(
Object.is(prev, next) === false),组件就重渲染。
所以我做的所有"最佳实践",都在回答: 我到底让组件订阅了什么?它的返回值稳定吗?
3. 为什么我不直接导出 store hook
我刻意不导出 useTodoStore 给业务组件使用,而是只导出:
useTodos()useTodoActions()useIncompleteCount()
原因:
-
避免误订阅整个 store
useTodoStore()相当于订阅state => state,任何字段变化都会触发 re-render
-
selector 逻辑集中
- 以后我想改变数据结构(比如从数组换成 map),只用改 hook,不用全局搜索替换
-
语义更强
- 业务组件里读起来像"声明依赖",而不是"实现细节拼装"
4. 为什么我把 actions 和 state 分离
我把 store 组织成:
{ todos, actions: { ... } }
这让我在组件里形成一种稳定模式:
- 订阅数据:
const todos = useTodos() - 触发事件:
const { addTodo } = useTodoActions()
这里有个我认为很"关键但容易忽略"的点:
actions 是稳定引用,它们不会随 todos 更新而改变。
因此:
只订阅 actions 的组件(例如 AddTodo)不会因为 todos 更新而重渲染。
这让渲染边界非常清晰。
5. persist 的坑:为什么我必须使用 partialize
我亲自踩过这个坑:addTodo is not a function。
本质原因:
- persist 会把 state JSON 序列化
- 函数无法被序列化
- rehydrate 后 actions 被覆盖成非函数对象
所以我必须写:
partialize: state => ({ todos: state.todos });
我的经验是:
persist 只存"数据",绝对不要存"行为"。
6. "computed" 在 Zustand 里怎么做?
Vue 有 computed,我在 Zustand 里用 selector 来模拟同等效果:
export const useIncompleteCount = () =>
useTodoStore(s => s.todos.reduce((acc, t) => acc + (t.completed ? 0 : 1), 0));
然后在 Footer 里只订阅这个派生值:
const incompleteCount = useIncompleteCount();
我特别强调这一点,因为这体现了"订阅最小依赖"的思想: Footer 不关心 todos 具体内容,只关心 count,所以我不订阅 todos。
7. 为什么我尽量不用 useShallow
useShallow 的作用我理解为:
当 selector 被迫返回一个新对象/数组(
{}/[])时,用 shallow compare 抵消引用变化。
但在这个 Demo 里,我通过:
- 原子 selector(
useTodos,useTodoActions,useIncompleteCount) - actions/state 分离
已经避免了"返回新对象"的场景。 因此我几乎不需要 useShallow。
如果我确实需要一次性返回多个 state 值,我才会用:
useTodoStore(useShallow(s => ({ a: s.a, b: s.b })));
8. 最终沉淀的"写 Zustand 的规则集"
自己定的规则基本是这些:
- 组件不直接用 store hook
- 导出语义化 hooks,集中 selector
- actions/state 分离
- persist 只持久化纯数据(partialize 必写)
- 派生状态优先用 selector(而不是强迫订阅原始 state)
- 默认不用 useShallow,只有在必须返回 object/array 时使用
- 能让组件依赖更小,就让依赖更小
9. 结语:我认为 Zustand 的"进阶"是什么
对我而言,Zustand 的进阶不是掌握多少 middleware, 而是我能否持续回答这两个问题:
- 这个组件到底订阅了什么?
- 我的 selector 返回值是稳定的吗?