Zustand 状态更新指南:浅合并、深层嵌套与 Immer/optics-ts/Ramda 不可变更新方案
【免费下载链接】zustand🐻 Bear necessities for state management in React项目地址: https://gitcode.com/gh_mirrors/zu/zustand
Zustand 通过set函数完成 store 内状态的更新,默认采用浅合并(shallow merge)语义,让扁平对象的状态更新变得极其简单;而面对深层嵌套对象时则需要显式地以不可变方式逐层复制。本文以 docs/learn/guides/updating-state.md 为核心,结合 src/vanilla.ts、src/middleware/immer.ts 与仓库测试用例,系统讲解扁平更新、嵌套对象更新的四种主流方案(手写展开、Immer、optics-ts、Ramda),并剖析set的浅合并与replace标志的底层实现,帮助你写出更简洁、类型安全、可维护的状态更新代码。
扁平更新(Flat updates):直接调用set即可
Zustand 更新状态非常简单:调用create时传入的set函数,传入新的状态(或返回新状态的函数),新状态会与 store 中已有状态进行浅合并(shallow merge)。这意味着你不需要手动展开旧状态,只需要写出真正变化的字段。
下面是官方文档给出的经典示例——一个管理firstName/lastName的 Person store:
import { create } from 'zustand' type State = { firstName: string lastName: string } type Action = { updateFirstName: (firstName: State['firstName']) => void updateLastName: (lastName: State['lastName']) => void } // Create your store, which includes both state and (optionally) actions const usePersonStore = create<State & Action>()((set) => ({ firstName: '', lastName: '', updateFirstName: (firstName) => set(() => ({ firstName: firstName })), updateLastName: (lastName) => set(() => ({ lastName: lastName })), }))在消费组件中,用 selector 精准挑选需要的状态与 action:
function App() { // "select" the needed state and actions, in this case, the firstName value // and the action updateFirstName const firstName = usePersonStore((state) => state.firstName) const updateFirstName = usePersonStore((state) => state.updateFirstName) return ( <main> <label> First name <input // Update the "firstName" state onChange={(e) => updateFirstName(e.currentTarget.value)} value={firstName} /> </label> <p> Hello, <strong>{firstName}!</strong> </p> </main> ) }几点实操要点:
- selector 驱动重渲染:组件通过
usePersonStore((state) => state.firstName)订阅指定切片,Zustand 的 React 绑定在 src/react.ts 中基于React.useSyncExternalStore实现,只有被 selector 选中的值发生变化时组件才会重渲染,因此不要省略 selector 以免订阅整个 store。 - 两种传参形式:
set既接受对象字面量(set({ firstName })),也接受函数set((state) => ({ ... }))。函数形式能拿到当前最新状态,是"基于旧值计算新值"场景下的推荐写法。在 src/vanilla.ts 中,setState会先判断partial是否为函数,若是则以当前state为参数调用它,得到最终nextState。 Object.is变更检测:setState内部用!Object.is(nextState, state)(见 src/vanilla.ts)判断状态是否真正变化,未变化则跳过通知,避免无效的订阅回调与重渲染。
浅合并的底层原理
浅合并的"魔法"来自 src/vanilla.ts 的setState实现。当未传入replace(即replace为undefined)且nextState是普通对象时:
state = (replace ?? (typeof nextState !== 'object' || nextState === null)) ? (nextState as TState) : Object.assign({}, state, nextState)Object.assign({}, state, nextState)把nextState的字段覆盖到state的副本上,只做一层合并;- 这正是 docs/learn/guides/immutable-state-and-merging.md 中强调的:因为
set默认合并,所以常见的计数更新可以直接写set((state) => ({ count: state.count + 1 })),而无需手写set((state) => ({ ...state, count: state.count + 1 })); - 若
nextState是null、undefined等非对象,则直接整体替换为nextState。
深层嵌套对象(Deeply nested object):必须不可变地逐层更新
当状态是如下这类深层嵌套结构时,情况就没那么简单了:
type State = { deep: { nested: { obj: { count: number } } } }由于set只做一层浅合并,deep字段下层的对象会整段被替换,因此更新嵌套状态必须确保过程是**不可变(immutable)**的——保留未变部分的对象引用,只替换变化路径上的对象。
常规方案:手动展开(spread)逐层复制
与 React / Redux 的常规做法一致:用展开运算符...逐层复制状态对象,并手动把新值合并进去:
normalInc: () => set((state) => ({ deep: { ...state.deep, nested: { ...state.deep.nested, obj: { ...state.deep.nested.obj, count: state.deep.nested.obj.count + 1 } } } })),这段代码功能正确但非常冗长——每加深一层,就要多写一层...展开。层级越深,样板代码越多,也越容易在展开时漏掉某个层级导致意外的引用共享或状态丢失。下面介绍几种让生活更轻松的替代方案。
使用 Immer:用可变写法实现不可变更新
很多人使用 Immer 来更新嵌套值。Immer 可以在任何需要更新嵌套状态的场景下使用,例如 React、Redux,当然也包括 Zustand。
利用 Immer 可以大幅缩短深层嵌套对象的更新代码。来看示例:
immerInc: () => set(produce((state: State) => { ++state.deep.nested.obj.count })),更新量减少非常明显!Immer 通过 Proxy 提供可变的draft,你在回调里直接++修改嵌套属性,Immer 会记录这些修改并产出一个新的不可变状态。
配合 immer middleware 使用
如果不想在每个 action 里手动包一层produce,Zustand 官方提供了 immer 中间件 src/middleware/immer.ts。它的核心实现是重写setState:
store.setState = (updater, replace, ...args) => { const nextState = ( typeof updater === 'function' ? produce(updater as any) : updater ) as ((s: T) => T) | T | Partial<T> return set(nextState, replace as any, ...args) }即当 updater 是函数时自动套上produce,于是你可以直接写:
import { create } from 'zustand' import { immer } from 'zustand/middleware/immer' export const useCountStore = create<State & Actions>()( immer((set) => ({ count: 0, increment: (qty: number) => set((state) => { state.count += qty }), decrement: (qty: number) => set((state) => { state.count -= qty }), })), )注意使用中间件时create后面需要保留多余的括号create<T>()(...),具体原因见 advanced-typescript.md。需要先安装 Immer 作为直接依赖:npm install immer。仓库的 tests/immer.test.tsx 验证了函数 updater 会经produce处理,同时 tests/middlewareTypes.test.tsx 验证了其类型推导——在回调中直接state.count = get().count + 1也能获得完整类型检查。
使用 Immer 的注意事项(Gotchas)
请务必阅读官方文档中列出的 immer-middleware.md 中记录的注意事项:
- 订阅没有被调用:使用 Immer 时务必遵守 Immer 的规则。例如类对象需要添加
[immerable] = true才能被 Proxy 代理。如果没这么做,Immer 仍会直接修改对象(而非通过代理),从而同时改动当前状态;由于 Zustand 会用Object.is检查状态是否真的变化(见 src/vanilla.ts),前后状态相等时就会跳过订阅回调。 replace标志依然生效:中间件把replace原样透传给底层set,tests/immer.test.tsx 验证了setState({ a: 9 }, true)会整体替换状态。
使用 optics-ts:无 Proxy 的函数式光学
另一个选项是使用 optics-ts:
opticsInc: () => set(O.modify(O.optic<State>().path("deep.nested.obj.count"))((c) => c + 1)),optics-ts 的核心思路是"光学(optic)"——用O.optic<State>().path("deep.nested.obj.count")描述状态中的目标路径,再用O.modify(optic)(fn)在该路径上应用变换函数。与 Immer 不同的是,optics-ts 不使用 Proxy,也不依赖可变语法:整个更新过程是纯函数式的,你传入的是一个显式的路径描述与纯函数。
使用 Ramda:函数式工具库的路径修改
还可以使用 Ramda:
ramdaInc: () => set(R.modifyPath(["deep", "nested", "obj", "count"], (c) => c + 1)),R.modifyPath接收一个路径数组(或路径描述)和一个变换函数,同样以纯函数方式返回更新后的新对象,不会原地修改。Ramda 和 optics-ts 都带有完善的类型定义,与 TypeScript 配合良好("Both ramda and optics-ts also work with types")。
四种方案对比与选型建议
| 方案 | 代码量 | 实现机制 | 类型支持 | 适用场景 |
|---|---|---|---|---|
| 手动展开(spread) | 冗长 | 逐层...复制 | 原生良好 | 层级浅、更新频率低 |
| Immer / immer 中间件 | 极少 | Proxy 可变 draft,自动不可变 | 中间件提供完整推导 | 深层嵌套、复杂不可变数据 |
| optics-ts | 少 | 光学(optic)路径,纯函数 | 完善 | 偏好函数式、需显式路径 |
| Ramda | 少 | modifyPath纯函数 | 完善 | 已在用 Ramda 的工具函数风格 |
选型提示:
- 若项目已经有 Immer 依赖,或状态结构很深且更新频繁,推荐 immer 中间件——它把
produce封装进set,无需在每个 action 手动包裹; - 若偏好函数式风格、不希望引入 Proxy 与可变语法,optics-ts 与 Ramda 都是干净的选择,且两者都支持类型;
- 任何情况下都不要忘记
set只做一层浅合并,深层嵌套必须借助上述任一方案保证不可变性。
结合源码理解set:合并、替换与更新通知
把 docs/learn/guides/updating-state.md 与 immutable-state-and-merging.md 两篇文档对照 src/vanilla.ts 的实现,可以得到完整的set行为画像:
- 接受对象或函数:
partial为函数时先以当前state调用得到nextState; - 变更检测:
Object.is(nextState, state)相等则直接返回,不触发任何通知; - 合并 or 替换:
- 未传
replace(默认undefined)且nextState为对象 →Object.assign({}, state, nextState)浅合并; - 传了
replace = true,或nextState不是对象 → 直接整体替换;
- 未传
- 通知订阅者:
listeners.forEach((listener) => listener(state, previousState))向所有订阅者广播新状态与旧状态。
replace标志的用法(来自 immutable-state-and-merging.md):
set((state) => newState, true)当你需要禁用合并行为、用全新对象整体覆盖状态(例如重置 store 或替换整个数据快照)时使用。这个标志在 src/vanilla.ts 的类型定义中也有体现:replace?: false时允许Partial<T>,而replace: true时要求完整的新状态。
延伸阅读
- immutable-state-and-merging.md:浅合并、嵌套对象更新与
replace标志的官方说明 - immer-middleware.md:immer 中间件的安装、使用与 Gotchas 完整文档
- src/vanilla.ts:
createStore/setState核心实现 - src/middleware/immer.ts:immer 中间件源码
- tests/immer.test.tsx:immer 中间件行为测试
- 官方在线演示:https://stackblitz.com/edit/vitejs-vite-j6bjdygu(updating-state 配套 Demo)
【免费下载链接】zustand🐻 Bear necessities for state management in React项目地址: https://gitcode.com/gh_mirrors/zu/zustand
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考