创建日期:2026-10-19 | 最近更新:2026-10-19 本文以 zustand 5.0.15(npm latest,2026-08-13 更新)为准,实测环境 Node 24.19.0 + React 19.3.1。带
[实测]标记的输出都是本机真实运行结果。
Zustand 101:React 与无框架(vanilla)两种用法
一句话:Zustand 的核心只是一个闭包里的 state + 一组监听器。React 用法给你一个 hook,无框架用法给你一组 getState / setState / subscribe API——两者共用同一套 store 实现,可以随时互相打通。
这篇文章分两条线讲:
- React 线:
create创建 store、selector 控制重渲染、useShallow、组件外的读写、transient update; - 无框架线:
createStore创建 vanilla store,用原生 DOM 事件和订阅把它跑起来,以及订阅语义、subscribeWithSelector、replace等细节。
官方文档:zustand.docs.pmnd.rs,源码:github.com/pmndrs/zustand。
1. 先拆开看:zustand 到底有多小
Zustand 没有 Proxy、没有依赖收集、没有 provider。zustand/vanilla 的核心逻辑简化后大致是这样(照 5.0.15 源码整理):
const createStoreImpl = (createState) => {
let state
const listeners = new Set()
const setState = (partial, replace) => {
const nextState = typeof partial === 'function' ? partial(state) : partial
if (!Object.is(nextState, state)) {
const previousState = state
const shouldReplace =
replace ?? (typeof nextState !== 'object' || nextState === null)
state = shouldReplace ? nextState : Object.assign({}, state, nextState)
listeners.forEach((listener) => listener(state, previousState))
}
}
const getState = () => state
const subscribe = (listener) => {
listeners.add(listener)
return () => listeners.delete(listener)
}
const api = { setState, getState, getInitialState, subscribe }
const initialState = (state = createState(setState, getState, api))
return api
}
从这段代码能直接读出三个关键事实:
- 通知的单位是整个 state 对象:
Object.is(nextState, state)不相等就通知所有监听器,比较的是引用,不是逐字段深比较; setState默认做一层浅合并:Object.assign({}, state, nextState),所以改一个字段不会把 actions 冲掉;- 原地修改不会触发通知:
store.getState().user.name = 'b'改的是同一个对象引用,Object.is判定没变,监听器一次都不会跑。
create 则是在 createStore 外面包了一层 React hook(用 useSyncExternalStore 订阅),并把 setState / getState / getInitialState / subscribe 挂到了返回的 hook 函数上。所以 React 里 useStore.getState() 能直接用,本质是同一个 store 对象。
2. React 用法:create 返回一个 hook
2.1 建 store:state 和 actions 放一起
npm i zustand
v5 的 peer dependency 要求 React 18+(本机实测用的是 React 19.3.1)。
// stores/counter.ts
import { create } from 'zustand'
type CounterState = {
count: number
text: string
other: number
inc: () => void
setText: (text: string) => void
bumpOther: () => void
}
export const useCounterStore = create<CounterState>()((set) => ({
count: 0,
text: 'hello',
other: 0,
inc: () => set((s) => ({ count: s.count + 1 })),
setText: (text) => set({ text }),
bumpOther: () => set((s) => ({ other: s.other + 1 })),
}))
TypeScript 用 create<T>()(...) 的柯里化写法;直接 create<T>(...) 在需要中间件时容易推导不出来,官方推荐记住这个双调用的形状。
官方推荐的 Flux 风格是:全局一个 store(大了就拆 slices),只用 set / setState 改状态,actions 与 state 放在一起。参见 Flux inspired practice。如果你更喜欢把 actions 放在 store 外面,也可以直接用 useCounterStore.setState(...) 定义模块级函数,见 Practice with no store actions。
2.2 组件里用:selector 是重渲染的边界
import { useCounterStore } from './stores/counter'
function Counter() {
const count = useCounterStore((s) => s.count)
return <p>count: {count}</p>
}
function Text() {
const text = useCounterStore((s) => s.text)
return <p>text: {text}</p>
}
function Whole() {
const state = useCounterStore() // 不传 selector = 订阅整个 state
return <p>other: {state.other}</p>
}
function IncButton() {
const inc = useCounterStore((s) => s.inc)
return <button onClick={() => inc()}>+1</button>
}
不需要 Provider,store 是一个模块级单例,任何组件直接 import。每一次 useCounterStore(selector) 都是独立订阅:selector 的返回值没变(Object.is),这个组件就不重渲染。
[实测] 用上面四个组件跑出来(数字为累计渲染次数):
| 操作 | Counter(选 count) | Text(选 text) | Whole(不传 selector) | PickShallow(见 2.3) |
|---|---|---|---|---|
| 首次渲染 | 1 | 1 | 1 | 1 |
inc() | 2 | 1 | 2 | 2 |
bumpOther() | 2 | 1 | 3 | 2 |
setText('world') | 2 | 2 | 4 | 3 |
两个结论:
- 只选
count的组件,在text/other变化时一次都没重渲染; - 不传 selector 的
Whole每次任何状态变化都重渲染——它订阅的是整个 state 对象,而浅合并每次都会产生新对象。
另外,只选 action 的组件永远不会因为数据变化而重渲染:[实测] 连续 inc() 5 次,只选 inc 的组件渲染次数保持 1,因为 action 函数引用没变。这是把 actions 放进 store 的一个附带好处。
2.3 返回对象/数组要用 useShallow
如果 selector 每次返回一个新对象,React 会认为状态一直在变:
// 错误示范:每次 selector 都返回新的 { count, text }
const { count, text } = useCounterStore((s) => ({
count: s.count,
text: s.text,
}))
[实测] 在 React 19.3.1 + zustand 5.0.15 下,这段代码在组件挂载后直接抛:
Maximum update depth exceeded. This can happen when a component repeatedly calls
setState inside componentWillUpdate or componentDidUpdate. React limits the number
of nested updates to prevent infinite loops.
原因就是 zustand v5 的 behavioral change:create 返回的 hook 不再支持自定义 equality function,selector 返回新引用会被 useSyncExternalStore 当成「一直在变」。官方迁移文档写得很明确:v5 中 selector 返回新引用可能导致无限循环(Migrating to v5)。
正确写法是 useShallow:
import { useShallow } from 'zustand/react/shallow'
const { count, text } = useCounterStore(
useShallow((s) => ({ count: s.count, text: s.text })),
)
// 数组同理
const [count, text] = useCounterStore(
useShallow((s) => [s.count, s.text]),
)
useShallow 在内部用一层浅比较做 memo:浅比较相等就返回上一次的引用,React 就不会重渲染。[实测] 把上面的 PickShallow 换成 useShallow 后,bumpOther() 不再触发它渲染,只有 count / text 真正变化时才渲染(表格最后一列)。
只选单个原始值时不需要
useShallow,useCounterStore((s) => s.count)就够了。useShallow是给「一次选多个值、且返回值是新对象/新数组」的场景用的。如果确实需要自定义 equality function(而不只是浅比较),官方迁移文档给的替代品是zustand/traditional里的createWithEqualityFn。
2.4 transient update:订阅但不重渲染
高频变化(鼠标位置、滚动、拖拽)如果每次都走 React 渲染会很浪费。zustand 的 subscribe 可以在 useEffect 里直接改 DOM 或 ref,完全绕过渲染:
import { useEffect, useRef } from 'react'
import { useCounterStore } from './stores/counter'
function TransientCount() {
const ref = useRef<HTMLSpanElement>(null)
useEffect(
() =>
useCounterStore.subscribe((state) => {
if (ref.current) ref.current.textContent = `count=${state.count}`
}),
[],
)
return <span ref={ref}>count=0</span>
}
[实测] 连续改 5 次 count:组件渲染次数保持 1,页面上的文本已经变成 count=5。这就是 README 里说的 "transient updates"。
注意 useEffect 的返回值是 subscribe 返回的退订函数,组件卸载时自动退订;不要漏掉这一步。
2.5 组件外读写:getState / setState / subscribe
create 返回的 hook 上挂着完整的 store API:
// 非响应式地读当前值(不会订阅)
const count = useCounterStore.getState().count
// 在事件回调、异步函数、普通 .ts 模块里改状态
useCounterStore.getState().inc()
useCounterStore.setState({ text: 'outside' })
// 监听全部变化,返回退订函数
const unsub = useCounterStore.subscribe((state, prevState) => {
console.log(prevState.count, '->', state.count)
})
unsub()
一个官方提醒:不要把这种「模块级 store + 组件外初始化」的模式用在 React Server Components 里(Next.js 13+ 常见),可能带来意外 bug 和用户隐私问题(见 README 的 warning 与 discussion #2200)。RSC 里更适合把状态留在客户端组件边界内。
2.6 需要多个独立实例时:vanilla store + context
create 返回的是模块级单例。如果同一页面需要多个互不干扰的计数器(比如多个标签页、多个画布),官方推荐的做法是 createStore + React context + useStore:
import { createContext, useContext, useState, type ReactNode } from 'react'
import { createStore, useStore, type StoreApi } from 'zustand'
type CounterStore = {
count: number
inc: () => void
}
const CounterContext = createContext<StoreApi<CounterStore> | null>(null)
export function CounterProvider({ children }: { children: ReactNode }) {
const [store] = useState(() =>
createStore<CounterStore>()((set) => ({
count: 0,
inc: () => set((s) => ({ count: s.count + 1 })),
})),
)
return <CounterContext.Provider value={store}>{children}</CounterContext.Provider>
}
export function useCounter<T>(selector: (state: CounterStore) => T): T {
const store = useContext(CounterContext)
if (store === null) {
throw new Error('useCounter must be used within CounterProvider')
}
return useStore(store, selector)
}
这里的 useStore(store, selector) 就是下一节的主角:把一个 vanilla store 接进 React。官方文档 useStore 给了三种形态:全局 vanilla store、动态全局 store(用 Map 按 key 管理)、通过 context 传入的 scoped store。
3. 无框架用法:createStore + 订阅
3.1 导入与 API
import { createStore } from 'zustand/vanilla'
createStore 不依赖 React,返回一个纯对象:
| API | 作用 |
|---|---|
getState() | 读取当前 state 快照(非响应式) |
setState(partial, replace?) | 更新 state;partial 可以是对象或 (state) => next;默认浅合并 |
getInitialState() | 读取初始 state(后续更新不影响它) |
subscribe(listener) | 注册监听器,返回退订函数;每次状态更新同步调用 (state, prevState) |
因为没有 hook,「响应式」要你自己接:订阅里写渲染逻辑。原生 DOM、Canvas、WebSocket 层、Node 脚本、CLI 都适用。
3.2 一个完整例子:原生 DOM 计数器
store(和 React 版共用同一套心智模型):
// counter-store.js
import { createStore } from 'zustand/vanilla'
export const counterStore = createStore((set) => ({
count: 0,
inc: () => set((s) => ({ count: s.count + 1 })),
dec: () => set((s) => ({ count: s.count - 1 })),
}))
页面:
<button id="dec">-</button>
<span id="count">0</span>
<button id="inc">+</button>
接线:
// main.js
import { counterStore } from './counter-store.js'
const $count = document.querySelector('#count')
const render = ({ count }) => {
$count.textContent = count
}
// 先手动渲染一次初始值,再订阅后续变化
render(counterStore.getInitialState())
counterStore.subscribe(render)
document.querySelector('#inc').addEventListener('click', () => {
counterStore.getState().inc()
})
document.querySelector('#dec').addEventListener('click', () => {
counterStore.getState().dec()
})
[实测] 用 jsdom 跑同一份逻辑:
初始: 0
点击两次 + 后: 2
再点一次 - 后: 1
有两个容易忽略的点:
subscribe不会立即调用回调,所以要先render(getInitialState())做首屏;- 订阅回调收到的是整个 state,没有 selector。想只订阅某个切片,用 3.4 的
subscribeWithSelector,或者自己在回调里比较。
3.3 订阅的精确语义(这几个行为值得记住)
[实测] 基于 zustand 5.0.15 的 createStore:
| 操作 | 是否通知 | 原因 |
|---|---|---|
store.getState().user.name = 'b' | 否 | 原地修改,state 引用没变 |
setState({ n: 0 })(值本来也是 0) | 是 | 浅合并生成了新对象,Object.is 判定不等 |
setState(sameSnapshot, true) | 否 | 传入同一引用,Object.is 相等 |
setState({ n: 0 }, true)(新对象、值相同) | 是 | 引用不同,zustand 不做深比较 |
这就是为什么 zustand 一直强调不可变更新:不是框架要求,而是「引用变化 = 通知」这套机制的要求。嵌套对象也要逐层展开:
set((s) => ({
user: { ...s.user, name: 'b' }, // 只展开一层不够,嵌套层也要新对象
}))
3.4 切片订阅:subscribeWithSelector
原生 subscribe 只能监听全部更新。要让「只有 n 变化时才回调」,加中间件:
import { createStore } from 'zustand/vanilla'
import { subscribeWithSelector } from 'zustand/middleware'
const store = createStore(
subscribeWithSelector(() => ({ n: 1, m: 100 })),
)
const log = []
const unsub = store.subscribe(
(state) => state.n, // selector
(n, prevN) => log.push({ n, prevN }), // 新值、旧值
{ fireImmediately: true }, // 可选:订阅时立即回调一次
)
store.setState({ m: 101 }) // 不影响 n
store.setState({ n: 2 })
console.log(log) // [ { n: 1, prevN: 1 }, { n: 2, prevN: 1 } ]
unsub()
[实测] 输出与上面一致:改 m 没有新增回调,改 n 才触发,回调第二个参数是旧值。第三个参数还支持 equalityFn,比如 selector 返回数组时配 shallow:
import { shallow } from 'zustand/shallow'
store.subscribe(
(s) => [s.a, s.b],
() => {},
{ equalityFn: shallow },
)
[实测] 返回新数组但浅比较相等时回调 0 次;a 真正变化后才回调 1 次。
3.5 replace 参数:替换整个 state
setState(partial, true)(或 store 内 set(next, true))会替换整个 state,而不是浅合并。[实测]:
store.setState({ text: 'replaced' }, true)
console.log(store.getState()) // { text: 'replaced' }
console.log(typeof store.getState().inc) // 'undefined'
actions 也会一起消失。所以 replace 通常只用于「state 本身就是原始值」或「确实要整体重置」的场景;日常更新不要顺手加 true。
3.6 中间件的一个边界
README 在 vanilla 章节有一条 warning(原文):
Note that middlewares that modify
setorgetare not applied togetStateandsetState.
也就是说:某些中间件改的是传给 state creator 的 set / get,而 store 上直接暴露的 getState / setState 未必经过那层包装。实际写业务时,优先调用 store 里定义的 actions(它们闭包内用的是被中间件包装过的 set),而不是绕过 actions 直接 store.setState(...),可以避免这类边界问题。
4. 同一份 store,两种吃法
create 和 createStore 不是两个世界:create = createStore + React hook。因此可以:
// store.ts —— 先创建 vanilla store
import { createStore, useStore } from 'zustand'
type CounterState = {
count: number
inc: () => void
}
export const counterStore = createStore<CounterState>()((set) => ({
count: 0,
inc: () => set((s) => ({ count: s.count + 1 })),
}))
// 组件里用 useStore 接进来
function Counter() {
const count = useStore(counterStore, (s) => s.count)
return <p>{count}</p>
}
// 组件外用同一份 store
counterStore.getState().inc()
counterStore.subscribe((s) => console.log(s.count))
useStore 的签名是 useStore(store, selector?),selector 省略时订阅整个 state。它同样有「selector 返回新引用」的问题,需要时把 useShallow 套在 selector 外层:
const { count } = useStore(counterStore, useShallow((s) => ({ count: s.count })))
选择建议:
| 场景 | 用什么 |
|---|---|
| 普通 React 应用,store 是全局单例 | create,最省事 |
| React + 非 React 代码共用同一份状态 | createStore + useStore |
| 同一组件需要多个独立 store 实例 | createStore + context(2.6) |
| 纯 Node / 浏览器脚本 / 测试 | createStore,不需要 React |
| 单元测试 store 逻辑 | createStore,直接断言 getState() |
测试里尤其好用:store 就是一个普通对象,不需要挂组件树。
import { describe, it, expect } from 'vitest'
import { createStore } from 'zustand/vanilla'
describe('counter store', () => {
it('inc 只改 count', () => {
const store = createStore((set) => ({
count: 0,
text: 'hello',
inc: () => set((s) => ({ count: s.count + 1 })),
}))
store.getState().inc()
expect(store.getState().count).toBe(1)
expect(store.getState().text).toBe('hello')
})
})
5. 常见坑清单
- selector 返回新对象/新数组:v5 会无限循环,用
useShallow包一层;只选单个原始值时不需要。 - 不传 selector:
useStore()订阅整个 state,任何变化都重渲染;调试可以,生产要选 slice。 - 默认只浅合并一层:嵌套对象必须逐层展开,或者用 immer 中间件。
- 原地修改不触发通知:
getState().x.y = 1不会更新任何订阅者。 replace = true会清掉 actions:除非确实要整体替换。subscribe不立即执行:首屏要先getInitialState()手动渲染一次。- vanilla 的
subscribe没有 selector:想按切片订阅,装subscribeWithSelector。 - 高频更新别走渲染:React 里用
subscribe+ ref/DOM 做 transient update。 - 不要在 RSC 里用模块级 store 加状态:官方明确不推荐。
set/get/store不要在初始化时同步调用:官方 API 文档提醒,它们只应在 store 创建完成后的 action 里使用。
6. 小结
Zustand 的「小」不是少写代码,而是心智模型小:
- 一个 state 对象、一个
setState、一组监听器; - React 适配层只是
useSyncExternalStore+ selector; - 无框架用法就是直接操作那个对象,自己决定什么时候渲染。
把这两条线都学会之后,选型会变得很自然:React 里用 create 拿 hook;需要跨 React / 非 React 边界时用 createStore + useStore;纯逻辑和测试直接用 createStore。