React 基础体系 · 第 47/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。

Zustand 状态管理:Store、Selector、订阅、持久化和 SSR

Zustand 是一个面向 React 的轻量状态管理库。它的核心不是“把所有状态放进一个全局对象”,而是提供一个可直接读写、可被 React 订阅的 Store,并允许组件只订阅自己关心的状态切片。

要正确使用 Zustand,需要同时理解五个概念:

  • Store:保存状态和修改状态的方法。
  • Selector:从完整状态中选择组件需要的部分。
  • 订阅:状态变化后通知 React 组件或普通 JavaScript 代码。
  • 持久化:把部分状态写入浏览器存储,并在下次启动时恢复。
  • SSR:服务端渲染时如何创建、传递和恢复状态,避免请求之间污染和客户端水合不一致。

Zustand 不负责服务端数据加载、路由数据生命周期或缓存失效策略。React Router 的 loaders/actions 更适合路由级数据加载;Redux Toolkit 则提供更明确的 reducer、action 和中间件约束。Zustand 更适合局部或跨组件的客户端状态,例如主题、侧边栏状态、筛选条件、编辑草稿和购物车。


一、先建立 Store 的心智模型

1. Store 是状态、更新函数和读取接口的组合

一个 Store 可以形式化为:

S=(X,U,R,L)S = (X, U, R, L)

其中:

  • XX 是当前状态;
  • UU 是更新状态的操作集合;
  • RR 是读取当前状态的接口;
  • LL 是订阅者集合。

在 Zustand 中,常见的 Store 状态大致如下:

type CounterState = {
  count: number
  increment: () => void
  decrement: () => void
}

这里 count 是数据,incrementdecrement 是改变数据的方法。把更新逻辑放进 Store,而不是散落到各个组件中,可以使状态转换具有单一来源。

2. 创建一个最小 Store

安装依赖:

npm install zustand

创建 Store:

// src/stores/counter-store.ts
import { create } from 'zustand'

type CounterState = {
  count: number
  increment: () => void
  decrement: () => void
  reset: () => void
}

export const useCounterStore = create<CounterState>((set) => ({
  count: 0,

  increment: () => {
    set((state) => ({
      count: state.count + 1,
    }))
  },

  decrement: () => {
    set((state) => ({
      count: state.count - 1,
    }))
  },

  reset: () => {
    set({ count: 0 })
  },
}))

组件使用时:

import { useCounterStore } from './stores/counter-store'

export function Counter() {
  const count = useCounterStore((state) => state.count)
  const increment = useCounterStore((state) => state.increment)
  const decrement = useCounterStore((state) => state.decrement)

  return (
    <div>
      <p>{count}</p>
      <button onClick={decrement}>-</button>
      <button onClick={increment}>+</button>
    </div>
  )
}

这段代码包含三条数据流:

  1. 初始状态是 { count: 0 }
  2. 点击按钮后调用 increment
  3. set 生成新的状态,Zustand 通知订阅者,依赖 count 的组件重新渲染。

更新函数使用函数形式:

set((state) => ({
  count: state.count + 1,
}))

这是因为更新依赖旧值。它比下面这种形式更安全:

set({
  count: count + 1,
})

后者依赖某个外部作用域中的 count,在连续事件、异步回调或闭包中更容易读取过期值。

3. set 的合并语义和替换语义

对于对象状态,默认的 set 是浅合并:

type UserState = {
  user: {
    id: string
    name: string
  }
  loggedIn: boolean
}
set({
  loggedIn: true,
})

结果相当于保留其他顶层字段:

{
  user: 当前 user,
  loggedIn: true,
}

但它不是深合并。下面的更新会覆盖整个 user 对象:

set({
  user: {
    name: 'Ada',
  },
})

这会丢失 user.id。正确的嵌套更新需要显式展开:

set((state) => ({
  user: {
    ...state.user,
    name: 'Ada',
  },
}))

set 的第二个参数可以请求整体替换:

set(
  {
    count: 0,
    increment: () => {},
  },
  true,
)

true 表示替换整个 Store 状态,而不是浅合并。使用它时,如果忘记重新放入操作函数,Store 的方法也会被删除。因此整体替换通常只适合明确的重置场景,不应随意使用。


二、Store 不等于 React Hook

create 返回的对象同时具备 React Hook 能力和 Store API:

const useCounterStore = create<CounterState>(...)

在组件中,它表现为 Hook:

const count = useCounterStore((state) => state.count)

在普通 JavaScript 代码中,它又可以直接读取和订阅:

const current = useCounterStore.getState()

current.increment()

const unsubscribe = useCounterStore.subscribe((state, previousState) => {
  console.log('状态变化', {
    current: state,
    previous: previousState,
  })
})

unsubscribe()

这里存在一个重要边界:

  • 在 React 组件中,使用 Hook 形式可以让 React 管理渲染;
  • 在事件处理器、路由拦截器、WebSocket 回调等普通代码中,可以使用 getStatesubscribe
  • 不能在任意普通函数中违反 React Hook 规则地调用 useCounterStore(selector)

例如,下面是合理的:

export function getCurrentToken() {
  return useAuthStore.getState().token
}

但不要把 Store 读取封装成一个在组件外调用的“伪 Hook”,再期待它自动触发 React 更新。


三、Selector:组件为什么不必订阅整个 Store

1. Selector 的定义

Selector 是一个函数:

f:XYf: X \rightarrow Y

它从完整 Store 状态 XX 中计算组件需要的值 YY

例如:

const count = useCounterStore((state) => state.count)

这里:

(state) => state.count

就是 Selector。

如果 Store 从:

{
  count: 0,
  increment: fn,
  decrement: fn
}

变成:

{
  count: 1,
  increment: fn,
  decrement: fn
}

Selector 的结果从 0 变为 1,组件需要重新渲染。

如果另一个字段变化,但 Selector 的结果仍然相同,组件通常不需要因为该字段变化而重新渲染。

2. Zustand 如何判断 Selector 结果是否变化

React 绑定通常使用 Object.is 比较 Selector 前后返回值:

rerender    ¬Object.is(f(Xnew),f(Xold))\text{rerender} \iff \neg Object.is(f(X_{new}), f(X_{old}))

对于基本值,这通常符合预期:

const count = useCounterStore((state) => state.count)

但对象和数组是引用类型:

const data = useCounterStore((state) => ({
  count: state.count,
  increment: state.increment,
}))

每次执行 Selector 都会创建一个新对象。即使 countincrement 实际没有变化:

Object.is(
  { count: 1, increment: fn },
  { count: 1, increment: fn },
) // false

组件仍可能因引用变化而重新渲染。

更清晰、通常也更高效的写法是拆分订阅:

const count = useCounterStore((state) => state.count)
const increment = useCounterStore((state) => state.increment)

如果确实需要返回对象或数组,可以使用 useShallow

import { useShallow } from 'zustand/react/shallow'

const selected = useCounterStore(
  useShallow((state) => ({
    count: state.count,
    increment: state.increment,
  })),
)

useShallow 会对选择结果进行浅比较。它只能比较第一层字段;如果对象中有深层嵌套对象,深层引用仍需由应用自己保证稳定或拆分选择。

3. Selector 必须是纯函数

Selector 的职责是读取或计算,不应产生副作用:

// 正确
const total = useCartStore((state) =>
  state.items.reduce((sum, item) => sum + item.price * item.quantity, 0),
)

下面的做法有问题:

const items = useCartStore((state) => {
  analytics.track('read-cart')
  return state.items
})

Selector 可能在渲染、订阅检查或开发模式下多次执行。把网络请求、日志上报、写入存储等副作用放进 Selector,会导致重复执行和难以诊断的行为。

4. 派生状态不一定需要存进 Store

购物车总价是一个典型派生值:

type CartItem = {
  id: string
  price: number
  quantity: number
}

type CartState = {
  items: CartItem[]
}

可以直接选择:

const total = useCartStore((state) =>
  state.items.reduce(
    (sum, item) => sum + item.price * item.quantity,
    0,
  ),
)

如果把 total 同时存储在 Store 中,就需要维护不变量:

total=i=1npricei×quantityitotal = \sum_{i=1}^{n} price_i \times quantity_i

任何修改 items 的路径都必须同步修改 total。一旦存在漏更新,状态就不一致。因此低成本派生值通常适合用 Selector 计算;只有计算代价高、需要缓存,或它本身是独立领域状态时,才考虑单独存储。


四、订阅:React 更新和普通代码监听是两条路径

1. 基础订阅

Store 的订阅关系可以抽象为:

调用 set
  ↓
Store 状态改变
  ↓
通知订阅者
  ├─ React 组件订阅:由 React 处理重新渲染
  └─ 普通订阅者:执行回调

基础订阅:

const unsubscribe = useCounterStore.subscribe(
  (state, previousState) => {
    console.log('count:', previousState.count, '->', state.count)
  },
)

取消订阅非常重要:

unsubscribe()

如果在组件中手动订阅,应放进 useEffect 并返回清理函数:

import { useEffect } from 'react'
import { useCounterStore } from './stores/counter-store'

export function DebugPanel() {
  useEffect(() => {
    return useCounterStore.subscribe((state, previousState) => {
      if (state.count !== previousState.count) {
        console.log('count changed')
      }
    })
  }, [])

  return null
}

不过对于“订阅后更新组件 UI”的需求,优先直接使用:

const count = useCounterStore((state) => state.count)

不要用手动订阅复制 React 的工作。

2. 只订阅某个切片

要使用按选择结果订阅,通常可以组合 subscribeWithSelector

import { create } from 'zustand'
import { subscribeWithSelector } from 'zustand/middleware'

type AuthState = {
  token: string | null
  userId: string | null
  setToken: (token: string | null) => void
}

export const useAuthStore = create<AuthState>()(
  subscribeWithSelector((set) => ({
    token: null,
    userId: null,

    setToken: (token) => set({ token }),
  })),
)

然后订阅 token

const unsubscribe = useAuthStore.subscribe(
  (state) => state.token,
  (token, previousToken) => {
    if (token !== previousToken) {
      console.log('token changed')
    }
  },
)

unsubscribe()

这种形式的因果关系是:

  1. Store 的任意状态可能变化;
  2. Selector 分别计算新旧结果;
  3. 只有选择结果发生变化时,回调才执行。

如果还需要自定义相等比较,可以传入 equalityFn 等选项。使用时应确认项目当前 Zustand 版本的类型和中间件组合方式,因为不同主版本对默认 equality API 的暴露形式可能不同。

3. 订阅的典型用途

普通订阅适合处理不属于 React 渲染的副作用,例如:

const unsubscribe = usePlayerStore.subscribe(
  (state) => state.volume,
  (volume) => {
    audioElement.volume = volume
  },
)

它不适合代替状态更新本身:

usePlayerStore.subscribe((state) => {
  // 在这里根据状态再次 set 状态
})

如果回调无条件写回同一个 Store,很容易形成循环:

状态变化 → 订阅回调 → set → 状态变化 → ...

必须有明确的变化条件,或者把状态转换逻辑放入 Store 的 action 中。


五、异步 Action:Zustand 不限制请求方式,但不自动解决竞态

Zustand 的 set 可以在异步函数中调用:

type User = {
  id: string
  name: string
}

type UserState = {
  user: User | null
  loading: boolean
  error: string | null
  fetchUser: (id: string) => Promise<void>
}

export const useUserStore = create<UserState>((set) => ({
  user: null,
  loading: false,
  error: null,

  fetchUser: async (id) => {
    set({ loading: true, error: null })

    try {
      const response = await fetch(`/api/users/${id}`)

      if (!response.ok) {
        throw new Error(`HTTP ${response.status}`)
      }

      const user = (await response.json()) as User

      set({
        user,
        loading: false,
      })
    } catch (error) {
      set({
        loading: false,
        error: error instanceof Error ? error.message : '请求失败',
      })
    }
  },
}))

这段代码处理了基本错误路径:

  • 请求开始:loading = true
  • 成功:写入 user,结束加载;
  • 失败:写入 error,结束加载;
  • HTTP 非 2xx 也显式转成错误。

但它没有自动处理竞态。假设先调用:

fetchUser("a")
fetchUser("b")

如果请求 b 先返回,随后请求 a 才返回,最终 Store 可能错误地显示用户 a

一种简单的防护方式是记录请求序号:

type UserState = {
  user: User | null
  loading: boolean
  error: string | null
  requestId: number
  fetchUser: (id: string) => Promise<void>
}

export const useUserStore = create<UserState>((set, get) => ({
  user: null,
  loading: false,
  error: null,
  requestId: 0,

  fetchUser: async (id) => {
    const requestId = get().requestId + 1

    set({
      requestId,
      loading: true,
      error: null,
    })

    try {
      const response = await fetch(`/api/users/${id}`)

      if (!response.ok) {
        throw new Error(`HTTP ${response.status}`)
      }

      const user = (await response.json()) as User

      if (get().requestId !== requestId) {
        return
      }

      set({
        user,
        loading: false,
      })
    } catch (error) {
      if (get().requestId !== requestId) {
        return
      }

      set({
        loading: false,
        error: error instanceof Error ? error.message : '请求失败',
      })
    }
  },
}))

更复杂的场景可以使用 AbortController 取消旧请求,或使用专门的数据请求缓存库。Zustand 本身不会提供缓存、重试、失效、分页和请求去重这些服务端数据能力。


六、持久化:保存的是状态快照,不是 Store 的完整运行时

1. persist 的基本用法

persist 中间件可以把 Store 序列化到存储介质:

import { create } from 'zustand'
import { persist } from 'zustand/middleware'

type PreferencesState = {
  theme: 'light' | 'dark'
  sidebarOpen: boolean
  setTheme: (theme: 'light' | 'dark') => void
  setSidebarOpen: (open: boolean) => void
}

export const usePreferencesStore = create<PreferencesState>()(
  persist(
    (set) => ({
      theme: 'light',
      sidebarOpen: true,

      setTheme: (theme) => set({ theme }),
      setSidebarOpen: (sidebarOpen) => set({ sidebarOpen }),
    }),
    {
      name: 'preferences',
    },
  ),
)

默认情况下,name 是存储键,常见浏览器环境会使用 localStorage。持久化过程可以理解为:

set(newState)
  ↓
更新内存中的 Store
  ↓
序列化可持久化状态
  ↓
写入 storage["preferences"]

读取时:

创建 Store
  ↓
读取 storage["preferences"]
  ↓
反序列化
  ↓
将持久化数据合并到初始状态

2. 只持久化必要字段

不要把整个 Store 无条件持久化,尤其是包含运行时字段时:

type SessionState = {
  token: string | null
  user: User | null
  loading: boolean
  error: string | null
  login: (name: string, password: string) => Promise<void>
}

loadingerror 和函数都不应该作为业务快照保存。使用 partialize

export const useSessionStore = create<SessionState>()(
  persist(
    (set) => ({
      token: null,
      user: null,
      loading: false,
      error: null,

      login: async (name, password) => {
        set({ loading: true, error: null })

        try {
          const response = await fetch('/api/login', {
            method: 'POST',
            headers: {
              'Content-Type': 'application/json',
            },
            body: JSON.stringify({ name, password }),
          })

          if (!response.ok) {
            throw new Error('登录失败')
          }

          const user = (await response.json()) as User

          set({
            user,
            token: '由服务端返回的实际 token',
            loading: false,
          })
        } catch (error) {
          set({
            loading: false,
            error: error instanceof Error ? error.message : '登录失败',
          })
        }
      },
    }),
    {
      name: 'session',
      partialize: (state) => ({
        token: state.token,
        user: state.user,
      }),
    },
  ),
)

注意:partialize 只决定持久化内容,不会改变 Store 内存中仍然存在的字段。

3. 持久化不是安全存储

localStorage 中的数据可被同源 JavaScript 读取。只要页面存在 XSS,攻击脚本就可能读取其中的令牌。因此:

  • 不要因为使用 persist 就认为令牌安全;
  • 高敏感认证信息通常应由服务端通过 HttpOnlySecure、合适 SameSite 属性的 Cookie 管理;
  • 持久化购物车、主题和筛选条件的风险通常低于持久化访问令牌;
  • Store 中的敏感字段即使不写入持久化,也仍然可能被浏览器扩展、调试工具或页面脚本访问。

4. 版本、迁移和损坏数据

Store 结构变化后,旧存储数据可能不再兼容。可以提供版本和迁移:

type SettingsState = {
  theme: 'light' | 'dark'
  density: 'compact' | 'comfortable'
  setTheme: (theme: SettingsState['theme']) => void
}

export const useSettingsStore = create<SettingsState>()(
  persist(
    (set) => ({
      theme: 'light',
      density: 'comfortable',

      setTheme: (theme) => set({ theme }),
    }),
    {
      name: 'settings',
      version: 2,
      migrate: (persistedState, version) => {
        if (version === 1) {
          const oldState = persistedState as {
            theme?: 'light' | 'dark'
          }

          return {
            theme: oldState.theme ?? 'light',
            density: 'comfortable',
          }
        }

        return persistedState as SettingsState
      },
    },
  ),
)

迁移函数应该:

  1. 根据旧版本解释旧结构;
  2. 生成当前版本结构;
  3. 为缺失字段提供明确默认值;
  4. 对异常数据采取可恢复策略。

如果存储内容损坏,JSON 解析可能失败。生产代码应设计清除旧键、回退默认状态和上报错误的路径,而不是让应用在启动阶段永久失败。

5. SSR 下的持久化水合问题

浏览器的 localStorage 只存在于客户端。服务端无法读取它。因此同一个组件可能经历:

服务端:theme = "light"
客户端首次渲染:theme = "light"
客户端读取 localStorage 后:theme = "dark"

如果客户端首次渲染直接得到 "dark",而服务端 HTML 是 "light",就会出现水合不一致,可能表现为警告、闪烁或属性与文本不匹配。

可以等待持久化恢复完成:

import { useEffect, useState } from 'react'
import { usePreferencesStore } from './stores/preferences-store'

export function ThemeGate({
  children,
}: {
  children: React.ReactNode
}) {
  const [hydrated, setHydrated] = useState(false)

  useEffect(() => {
    setHydrated(true)
  }, [])

  if (!hydrated) {
    return <div>正在加载设置…</div>
  }

  return children
}

更精确的做法是利用 persist 的生命周期回调或 skipHydration 配置,在客户端明确调用 rehydrate。这适合需要严格控制恢复时机的应用,但代码必须按照当前 Zustand 版本的 persist API 书写,并为加载中状态提供稳定的服务端和客户端输出。


七、SSR:每个请求必须拥有独立 Store

1. 为什么不能在服务端使用模块级单例

浏览器中的模块级 Store 通常对应一个用户会话,但 Node.js 服务端进程会同时处理多个请求。如果这样写:

// 有风险的服务端写法
export const useUserStore = create<UserState>(() => ({
  user: null,
}))

并在服务端根据请求写入用户数据,那么请求 A 和请求 B 可能共享同一个 Store:

请求 A 写入 user=A
请求 B 开始渲染,读到 user=A
请求 B 的响应泄露请求 A 的数据

服务端请求之间共享模块缓存,因此 Store 必须按请求创建,而不是全局复用。

2. 使用 vanilla Store 工厂

Zustand 提供 zustand/vanilla 创建不依赖 React 的 Store:

// src/stores/session-store.ts
import { createStore } from 'zustand/vanilla'
import type { StoreApi } from 'zustand/vanilla'

export type SessionState = {
  user: {
    id: string
    name: string
  } | null
  setUser: (user: SessionState['user']) => void
}

export type SessionStore = StoreApi<SessionState>

export function createSessionStore(
  initialUser: SessionState['user'] = null,
): SessionStore {
  return createStore<SessionState>((set) => ({
    user: initialUser,
    setUser: (user) => set({ user }),
  }))
}

服务端处理每个请求时:

export async function renderPage(request: Request) {
  const user = await getUserFromRequest(request)
  const store = createSessionStore(user)

  const initialState = store.getState()

  return {
    initialState: {
      user: initialState.user,
    },
  }
}

这里的关键保证是:

requestirequestjstoreistorejrequest_i \neq request_j \Rightarrow store_i \neq store_j

也就是说,不同请求必须获得不同 Store 实例。

3. 在 React 中通过 Provider 注入 Store

可以使用 useStore 将 vanilla Store 连接到 React:

'use client'

import {
  createContext,
  useContext,
  useRef,
  type ReactNode,
} from 'react'
import { useStore } from 'zustand'
import type { SessionStore, SessionState } from './session-store'
import { createSessionStore } from './session-store'

const SessionStoreContext = createContext<SessionStore | null>(null)

export function SessionStoreProvider({
  initialUser,
  children,
}: {
  initialUser: SessionState['user']
  children: ReactNode
}) {
  const storeRef = useRef<SessionStore | null>(null)

  if (storeRef.current === null) {
    storeRef.current = createSessionStore(initialUser)
  }

  return (
    <SessionStoreContext.Provider value={storeRef.current}>
      {children}
    </SessionStoreContext.Provider>
  )
}

export function useSessionStore<T>(
  selector: (state: SessionState) => T,
): T {
  const store = useContext(SessionStoreContext)

  if (!store) {
    throw new Error(
      'useSessionStore 必须在 SessionStoreProvider 内使用',
    )
  }

  return useStore(store, selector)
}

组件使用:

export function UserName() {
  const user = useSessionStore((state) => state.user)

  return <span>{user ? user.name : '未登录'}</span>
}

useRef 的作用是保证同一个客户端 Provider 生命周期内只创建一次 Store。若在组件每次渲染时都执行:

const store = createSessionStore(initialUser)

那么 Store 引用会不断变化,订阅关系也会被不断重建,并且客户端状态可能被意外重置。

4. Next.js App Router 中的边界

在 Next.js App Router 等框架中,应明确区分:

  • Server Component:可以读取请求、调用服务端数据源,但不能直接使用依赖浏览器交互的 Zustand React Hook;
  • Client Component:使用 'use client',通过 Provider 和 useStore 订阅客户端状态;
  • 服务端初始数据:作为序列化 props 传给客户端 Provider;
  • 客户端交互状态:在 Provider 创建的 Store 中继续变化。

一种结构如下:

// app/page.tsx —— Server Component
import { SessionStoreProvider } from '@/stores/session-provider'

export default async function Page() {
  const user = await getUserFromRequest()

  return (
    <SessionStoreProvider initialUser={user}>
      <Dashboard />
    </SessionStoreProvider>
  )
}
// app/dashboard.tsx —— Client Component
'use client'

export function Dashboard() {
  const user = useSessionStore((state) => state.user)

  return <main>{user ? user.name : 'Guest'}</main>
}

服务端传给客户端的数据必须可序列化。不要把数据库连接、函数、请求对象或未脱敏的机密信息放进初始状态。

5. SSR 水合要求

水合要求服务端输出和客户端首次渲染得到相同的可见结果。形式上可以写成:

HTMLserver=Render(Stateserver)HTML_{server} = Render(State_{server})

Render(Stateclient,first)=HTMLserverRender(State_{client, first}) = HTML_{server}

因此:

State_client, first = State_server

如果服务端用的是 user = null,客户端却在第一次渲染时从浏览器存储读取出另一个用户,结果就可能不一致。

常见解决策略有三种:

  1. 把服务端真实初始数据序列化给客户端,服务端与客户端首屏使用同一份数据;
  2. 延后读取浏览器专属状态,例如在 useEffect 后再显示主题或本地草稿;
  3. 让服务端和客户端首屏都输出中性占位内容,待状态恢复后再显示真实内容。

不应把 suppressHydrationWarning 当成状态同步方案。它最多压制特定警告,不能修复错误的状态、事件绑定或用户可见内容。


八、持久化与 SSR 结合时的完整状态流程

一个同时使用 SSR 和 persist 的页面,可能有两种来源:

服务端请求数据 ───────┐
                      ├─ 首次 Store 状态
浏览器 localStorage ──┘

必须明确冲突优先级。例如用户主题可能遵循:

服务端 Cookie 主题
  ↓
客户端 localStorage 主题
  ↓
应用默认主题

但如果服务端无法读取 localStorage,服务端首屏只能使用 Cookie 或默认值。客户端恢复后可能改变主题。

如果业务要求首屏完全一致,主题更适合放进 Cookie,并由服务端和客户端共同读取;如果业务接受客户端恢复后的短暂变化,才适合单独依赖 localStorage

对于用户特定数据,不应简单地把服务端用户数据和本地持久化数据无条件合并。例如:

服务端购物车:用户账号购物车
本地购物车:匿名访客购物车

两者的合并规则应是明确业务逻辑:

  1. 服务端确认当前用户;
  2. 客户端读取匿名购物车;
  3. 调用服务端合并接口;
  4. 服务端返回合并后的权威结果;
  5. 清理或更新本地匿名数据。

仅靠 persist 的默认浅合并无法正确解决身份切换和数据归属问题。


九、完整示例:购物车 Store

下面的例子覆盖 Store、Selector、派生状态、异步结算和持久化。

import { create } from 'zustand'
import { persist } from 'zustand/middleware'

type Product = {
  id: string
  name: string
  price: number
}

type CartLine = Product & {
  quantity: number
}

type CartState = {
  items: CartLine[]
  adding: boolean
  error: string | null

  add: (product: Product) => void
  remove: (productId: string) => void
  clear: () => void
  checkout: () => Promise<void>
}

export const useCartStore = create<CartState>()(
  persist(
    (set, get) => ({
      items: [],
      adding: false,
      error: null,

      add: (product) => {
        set((state) => {
          const existing = state.items.find(
            (item) => item.id === product.id,
          )

          if (existing) {
            return {
              items: state.items.map((item) =>
                item.id === product.id
                  ? { ...item, quantity: item.quantity + 1 }
                  : item,
              ),
            }
          }

          return {
            items: [
              ...state.items,
              {
                ...product,
                quantity: 1,
              },
            ],
          }
        })
      },

      remove: (productId) => {
        set((state) => ({
          items: state.items
            .map((item) =>
              item.id === productId
                ? { ...item, quantity: item.quantity - 1 }
                : item,
            )
            .filter((item) => item.quantity > 0),
        }))
      },

      clear: () => {
        set({ items: [], error: null })
      },

      checkout: async () => {
        const items = get().items

        if (items.length === 0) {
          set({ error: '购物车为空' })
          return
        }

        set({ adding: true, error: null })

        try {
          const response = await fetch('/api/checkout', {
            method: 'POST',
            headers: {
              'Content-Type': 'application/json',
            },
            body: JSON.stringify({ items }),
          })

          if (!response.ok) {
            throw new Error(`结算失败:HTTP ${response.status}`)
          }

          set({
            items: [],
            adding: false,
          })
        } catch (error) {
          set({
            adding: false,
            error: error instanceof Error ? error.message : '结算失败',
          })
        }
      },
    }),
    {
      name: 'cart',
      partialize: (state) => ({
        items: state.items,
      }),
    },
  ),
)

组件只选择自己依赖的字段:

import { useCartStore } from './cart-store'

export function CartSummary() {
  const items = useCartStore((state) => state.items)
  const adding = useCartStore((state) => state.adding)
  const checkout = useCartStore((state) => state.checkout)

  const total = items.reduce(
    (sum, item) => sum + item.price * item.quantity,
    0,
  )

  return (
    <section>
      <p>商品种类:{items.length}</p>
      <p>总价:{total}</p>
      <button disabled={adding} onClick={() => void checkout()}>
        {adding ? '处理中…' : '结算'}
      </button>
    </section>
  )
}

这里 total 没有放进 Store,而是从当前 items 派生。checkout 调用 get() 获取提交时的购物车快照,并在请求成功后清空购物车。若结算接口支持幂等键,还应将幂等键纳入请求设计,避免用户重复点击导致重复订单;这不是 Zustand 自动提供的能力。


十、状态更新、订阅和 React 19 并发

Zustand 的 React 集成需要遵守 React 外部 Store 订阅协议。组件通过 Selector 读取外部 Store,React 负责在渲染过程中取得一致快照并安排更新。

这意味着不要在渲染期间直接修改 Store:

function BadComponent() {
  const count = useCounterStore((state) => state.count)

  if (count === 0) {
    useCounterStore.getState().increment()
  }

  return <p>{count}</p>
}

这会把副作用放进渲染阶段,可能造成重复更新、无限循环或并发渲染下的不稳定行为。应放入事件处理器或效果中:

function GoodComponent() {
  const count = useCounterStore((state) => state.count)
  const increment = useCounterStore((state) => state.increment)

  return (
    <button onClick={increment}>
      {count}
    </button>
  )
}

React 19 的并发渲染、Strict Mode 开发检查和 Server Components 并不会改变 Store 的业务状态模型,但会放大以下错误:

  • 在渲染函数中执行 set
  • Selector 每次返回不稳定的新对象;
  • 在组件渲染时反复创建 Store;
  • 服务端和客户端使用不同初始状态;
  • 异步请求返回后没有判断是否仍是当前请求。

因此,正确的原则不是“避免所有重新渲染”,而是保证:

Render 只负责描述 UIRender \text{ 只负责描述 UI}

Action 负责改变状态Action \text{ 负责改变状态}

Selector 负责纯读取Selector \text{ 负责纯读取}

Effect/Subscribe 负责外部副作用Effect/Subscribe \text{ 负责外部副作用}


十一、常见错误及诊断方法

错误一:把所有状态放进一个大 Store

Store 过大不会自动产生正确的模块边界。更严重的问题是不同领域的状态互相耦合,导致修改一个功能时影响不相关组件。

诊断方法:

  • 检查一个 action 是否需要修改多个不相关领域;
  • 检查组件是否频繁选择整个状态对象;
  • 检查持久化时是否无法区分哪些字段属于哪个业务。

可以按领域拆分:

authStore
cartStore
preferencesStore
editorStore

拆分不是硬性规范。如果多个状态转换必须原子完成,放在同一个 Store 也可能更合理。

错误二:组件订阅整个 Store

下面的写法会让组件依赖整个状态对象:

const state = useCartStore()

当购物车的任意字段变化时,组件都会收到更新。更具体的写法是:

const itemCount = useCartStore((state) => state.items.length)
const clear = useCartStore((state) => state.clear)

诊断可以通过 React DevTools、组件渲染日志和临时计数器确认某个组件是否在无关字段变化时重复渲染。不要仅凭渲染次数判断性能问题;如果组件很轻,额外渲染可能比复杂 Selector 优化更便宜。

错误三:用数组索引或对象新引用制造无意义更新

例如:

const selected = useCartStore((state) => [
  state.items.length,
  state.error,
])

每次选择都会创建新数组。若当前版本默认使用引用比较,应改为拆分选择,或使用浅比较工具:

const itemCount = useCartStore((state) => state.items.length)
const error = useCartStore((state) => state.error)

错误四:在服务端复用 Store 单例

失败表现包括:

  • 不同用户偶尔看到相同用户信息;
  • 压测下出现跨请求数据;
  • 本地开发无法复现,生产并发后出现;
  • SSR 输出内容与请求身份不匹配。

诊断重点是搜索服务端模块是否导出了模块级可变 Store,并确认 Store 创建位置是否位于请求处理函数或请求级 Provider 内。

错误五:把 localStorage 直接写在模块顶层

下面代码在服务端会失败:

const theme = localStorage.getItem('theme')

服务端没有浏览器的 localStorage。应让 persist 在客户端环境处理,或显式判断运行环境,并且不能只用环境判断掩盖水合不一致:

const isBrowser = typeof window !== 'undefined'

即使判断后不报错,服务端和客户端首屏状态仍可能不同。

错误六:以为持久化会自动处理登录退出

持久化只会保存和恢复配置的字段。退出登录时必须显式清理内存和存储数据:

const logout = () => {
  useSessionStore.getState().setUser(null)
  localStorage.removeItem('session')
}

如果 Store 通过 persist 保存了令牌,退出逻辑还必须清除对应持久化键。更安全的设计是让服务端 Cookie 成为认证权威来源,客户端 Store 只保存用于显示的非敏感用户快照。


十二、Zustand 与其他数据流工具的边界

Zustand 适合处理客户端状态,但不应把所有服务器数据都当作普通全局状态。

React Router

路由数据通常具有这些特征:

  • 与 URL 和路由层级关联;
  • 进入路由时加载;
  • 离开路由时失效或重新验证;
  • 需要处理 loader、action、重定向和错误边界。

这类数据优先使用 React Router 的数据 API。Zustand 可以保存用户在页面上的临时筛选、弹窗开关或编辑草稿,但不应重复实现完整路由数据加载机制。

Redux Toolkit

Redux Toolkit 更强调:

  • action 和 reducer 的显式状态转换;
  • 可序列化状态;
  • 中间件和开发工具;
  • 约束较强的团队协作模型。

Zustand 更少样板代码,Store 结构也更自由。自由意味着工程师需要自行决定 action 命名、模块边界、错误状态、持久化范围和 SSR 生命周期。它并不会自动为这些决策提供规范。


十三、选择 Zustand 时需要回答的几个问题

在引入 Store 前,应先区分状态类型:

组件内部临时状态       → useState / useReducer
URL 状态               → 路由参数、搜索参数
服务端缓存数据         → 路由数据 API 或专门请求缓存工具
跨组件客户端状态       → Zustand 等客户端 Store
跨请求服务端状态       → 数据库、缓存、服务端会话

如果状态只在一个组件中使用,提升到全局 Store 往往增加了生命周期和调试成本。如果状态需要跨页面恢复,才进一步考虑持久化。如果状态包含用户身份数据,还必须先确认它的权威来源是服务端还是浏览器。

一个可验证的设计通常满足以下条件:

  1. Store 的初始状态在客户端和服务端边界上明确;
  2. 每个修改路径都通过命名 action 进入;
  3. 组件通过具体 Selector 订阅;
  4. Selector 不产生副作用;
  5. 异步 action 明确加载、成功、失败和竞态路径;
  6. persist 只保存必要字段,并有版本迁移策略;
  7. SSR 为每个请求创建独立 Store;
  8. 认证和敏感数据没有因为方便而直接放入不安全的浏览器存储。

Zustand 的核心机制很小:保存状态、执行更新、比较 Selector 结果并通知订阅者。但一旦涉及持久化、异步请求和 SSR,真正决定正确性的就不再是 API 调用本身,而是状态来源、实例生命周期、引用相等性和服务端—客户端边界是否被明确建模。


系列导航与关联阅读

官方资料

本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。