Zustand 深度集成

Context 在高频更新场景性能不佳——任何值变化都触发所有消费者重渲染。Zustand 通过选择性订阅解决这个问题:组件只在自己订阅的字段变化时才重渲染。同时 API 极简(一个 create 函数)、不需要 Provider、gzip 后仅 ~1KB。

1. 为什么选 Zustand

维度ContextReduxZustand
选择性订阅❌ 全量重渲染✅ useSelector✅ 内置
样板代码多(action/reducer/selector)几乎零
需要 Provider
中间件丰富persist/devtools/immer
体积0~7KB~1KB

简单说:Zustand = Redux 的能力 + Context 的简洁度

Redux 在 2023 年之前是 React 状态管理的事实标准,但在 App Router 时代,它的 Provider 依赖和大量样板代码变得笨重。Zustand 没有 Provider 依赖、没有 Action/Reducer 概念,一个 create 函数搞定一切。

2. 基础用法

安装:pnpm add zustand

2.1 创建 Store

Store 是一个包含状态和操作的普通对象。set 函数用于更新状态——支持部分更新(只传需要改的字段)和函数式更新(基于当前状态计算新状态):

// stores/chat-store.ts
import { create } from 'zustand'
 
type Message = {
  id: string
  role: 'user' | 'assistant'
  content: string
  createdAt: Date
}
 
type ChatStore = {
  messages: Message[]
  isStreaming: boolean
  currentModel: string
  addMessage: (message: Message) => void
  setStreaming: (value: boolean) => void
  setModel: (model: string) => void
  clearMessages: () => void
}
 
export const useChatStore = create<ChatStore>((set) => ({
  // 状态
  messages: [],
  isStreaming: false,
  currentModel: 'gpt-4o',
 
  // 操作:函数式更新(基于当前状态)
  addMessage: (message) =>
    set((state) => ({ messages: [...state.messages, message] })),
 
  // 操作:部分更新(直接设置值)
  setStreaming: (isStreaming) => set({ isStreaming }),
  setModel: (currentModel) => set({ currentModel }),
  clearMessages: () => set({ messages: [] }),
}))

注意 set 默认是浅合并(shallow merge)——set(&#123; isStreaming: true &#125;) 只更新 isStreaming,不会影响 messagescurrentModel。这一点和 React 的 setState 行为一致。

2.2 选择性订阅

在组件中使用时,传入 selector 函数——这是 Zustand 性能优势的核心:

'use client'
import { useChatStore } from '@/stores/chat-store'
 
export function ChatInput() {
  // ✅ 只订阅 isStreaming —— messages 变化不会导致本组件重渲染
  const isStreaming = useChatStore((s) => s.isStreaming)
  const addMessage = useChatStore((s) => s.addMessage)
 
  return (
    <form onSubmit={(e) => {
      e.preventDefault()
      const content = new FormData(e.currentTarget).get('content') as string
      addMessage({ id: crypto.randomUUID(), role: 'user', content, createdAt: new Date() })
      e.currentTarget.reset()
    }}>
      <input name="content" disabled={isStreaming} placeholder="输入消息..." />
      <button type="submit" disabled={isStreaming}>发送</button>
    </form>
  )
}
// components/message-list.tsx — 只订阅 messages
'use client'
 
export function MessageList() {
  const messages = useChatStore((s) => s.messages)
 
  return (
    <div className="space-y-4">
      {messages.map((msg) => (
        <div key={msg.id} className={msg.role === 'user' ? 'text-right' : ''}>
          {msg.content}
        </div>
      ))}
    </div>
  )
}

对比 Context:如果用 Context 包含 &#123; messages, isStreaming, model &#125;,AI 流式回复时 messages 每秒更新几十次,ChatInput 也会跟着重渲染几十次(它并不需要 messages)。用 Zustand,ChatInput 只在 isStreaming 变化时重渲染——从 idlestreaming,再从 streamingidle,总共 2 次。

2.3 组件外访问 Store

Zustand 的 Store 是一个普通 JavaScript 对象,可以在组件外部访问——这在工具函数、事件监听中很有用:

// 在组件外获取/设置状态
const messages = useChatStore.getState().messages
useChatStore.setState({ isStreaming: false })
 
// 订阅变化(用于日志、分析等)
const unsubscribe = useChatStore.subscribe(
  (state) => console.log('Store changed:', state.messages.length),
)

3. 中间件

Zustand 的三个核心中间件各解决一个问题:

中间件解决什么安装
devtoolsRedux DevTools 调试,查看每次状态变化内置
immer简化深层嵌套的不可变更新pnpm add immer
persist状态持久化到 localStorage内置

3.1 immer — 简化不可变更新

没有 immer 时,更新嵌套对象需要多层 spread:

// ❌ 原生写法:更新最后一条消息的内容
updateLastMessage: (content) =>
  set((state) => ({
    messages: state.messages.map((msg, i) =>
      i === state.messages.length - 1
        ? { ...msg, content: msg.content + content }
        : msg,
    ),
  })),
 
// ✅ immer:直接"修改"(内部自动做不可变更新)
updateLastMessage: (content) =>
  set((state) => {
    const last = state.messages[state.messages.length - 1]
    if (last) last.content += content
  }),

嵌套越深,immer 的优势越明显。AI 聊天场景中频繁更新消息内容,immer 让代码更易读。

3.2 persist — 状态持久化

用户偏好(主题、默认模型、温度)适合持久化到 localStorage:

import { create } from 'zustand'
import { persist } from 'zustand/middleware'
 
export const useSettingsStore = create<SettingsStore>()(
  persist(
    (set) => ({
      theme: 'system' as 'light' | 'dark' | 'system',
      model: 'gpt-4o',
      temperature: 0.7,
      setTheme: (theme) => set({ theme }),
      setModel: (model) => set({ model }),
      setTemperature: (temperature) => set({ temperature }),
    }),
    {
      name: 'ai-chat-settings', // localStorage 的 key
      partialize: (state) => ({
        // 只持久化这三个字段,函数不会被序列化
        theme: state.theme,
        model: state.model,
        temperature: state.temperature,
      }),
    },
  ),
)

partialize 很重要——默认会序列化整个 Store,包括函数(会报错)。指定哪些字段需要持久化可以避免问题。

3.3 中间件组合

三者可以嵌套使用,顺序从外到内:devtools → persist → immer

import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
import { immer } from 'zustand/middleware/immer'
 
export const useChatStore = create<ChatStore>()(
  devtools(                           // 最外层:Redux DevTools 看到所有变化
    persist(                          // 中间层:自动持久化
      immer((set) => ({               // 最内层:immer 简化更新
        messages: [],
        isStreaming: false,
 
        addMessage: (message) =>
          set((state) => {
            state.messages.push(message)
          }),
 
        updateLastMessage: (content) =>
          set((state) => {
            const last = state.messages[state.messages.length - 1]
            if (last) last.content += content
          }),
      })),
      { name: 'chat-store', partialize: (s) => ({ messages: s.messages }) },
    ),
    { name: 'ChatStore' },
  ),
)
注意嵌套顺序

devtools 必须在最外层才能捕获所有中间件处理后的状态变化。如果把 devtools 放在 immer 内部,DevTools 看到的是 immer 处理前的 draft 状态,而不是最终状态。

4. Slice 模式

当 Store 超过 10 个字段或 5 个操作时,应该按领域拆分为 Slice。

4.1 拆分原则

  • 按功能域拆分:chat-slice(对话)、ui-slice(UI)、settings-slice(设置)
  • 每个 Slice 独立文件:方便团队协作
  • Slice 之间可以通过 get() 互相访问:但尽量减少跨 Slice 依赖
// stores/slices/chat-slice.ts
import type { StateCreator } from 'zustand'
 
export type ChatSlice = {
  messages: Message[]
  isStreaming: boolean
  addMessage: (message: Message) => void
  setStreaming: (value: boolean) => void
}
 
export const createChatSlice: StateCreator<ChatSlice & UISlice, [], [], ChatSlice> = (set) => ({
  messages: [],
  isStreaming: false,
  addMessage: (message) =>
    set((state) => ({ messages: [...state.messages, message] })),
  setStreaming: (isStreaming) => set({ isStreaming }),
})
 
// stores/slices/ui-slice.ts
export type UISlice = {
  sidebarOpen: boolean
  toggleSidebar: () => void
}
 
export const createUISlice: StateCreator<ChatSlice & UISlice, [], [], UISlice> = (set) => ({
  sidebarOpen: true,
  toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
})

4.2 合并 Slice

// stores/index.ts
import { create } from 'zustand'
import { createChatSlice, type ChatSlice } from './slices/chat-slice'
import { createUISlice, type UISlice } from './slices/ui-slice'
 
type AppStore = ChatSlice & UISlice
 
export const useAppStore = create<AppStore>()((...a) => ({
  ...createChatSlice(...a),
  ...createUISlice(...a),
}))

使用时依然通过 selector 订阅:useAppStore((s) => s.messages) 只在 messages 变化时重渲染,不受 sidebarOpen 变化影响。

5. SSR 兼容

5.1 Hydration 问题

persist 中间件从 localStorage 恢复数据,但 Server 端没有 localStorage。这导致:

  • Server 渲染时用默认值(如 theme: 'system'
  • Client Hydrate 时从 localStorage 读到用户设置(如 theme: 'dark'
  • HTML 不匹配 → Hydration Mismatch 错误

5.2 解决方案:延迟显示

核心思路:Server 渲染时显示骨架或默认值,等 Client Hydrate 完成后再显示持久化的真实值:

'use client'
import { useEffect, useState } from 'react'
 
export function useHydrated() {
  const [hydrated, setHydrated] = useState(false)
  useEffect(() => setHydrated(true), [])
  return hydrated
}
 
// 使用
function ModelDisplay() {
  const hydrated = useHydrated()
  const model = useSettingsStore((s) => s.model)
 
  if (!hydrated) return <Skeleton className="h-6 w-20" />
  return <span>{model}</span>
}
什么时候需要 useHydrated

只有使用了 persist 中间件的 Store 才需要。普通 Store 没有 localStorage 差异,不存在 Hydration 问题。

5.3 Server Component 初始化 Store

从数据库加载的数据需要在 Client 端填充到 Store 中。模式是 Server 查数据 → props 传给 Client → Client 初始化 Store:

// app/(dashboard)/chat/[id]/page.tsx — Server Component
export default async function ChatPage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params
  const messages = await getMessages(id)
  return <ChatClient initialMessages={messages} />
}
 
// components/chat-client.tsx — Client Component
'use client'
 
import { useEffect, useRef } from 'react'
import { useChatStore } from '@/stores/chat-store'
 
export function ChatClient({ initialMessages }: { initialMessages: Message[] }) {
  const initialized = useRef(false)
 
  useEffect(() => {
    if (!initialized.current) {
      useChatStore.setState({ messages: initialMessages })
      initialized.current = true
    }
  }, [initialMessages])
 
  return (
    <div className="flex flex-col h-full">
      <MessageList />
      <ChatInput />
    </div>
  )
}

useRef + initialized 确保只初始化一次——避免 React Strict Mode 下 useEffect 执行两次导致重复设置。

6. 常见坑

6.1 Selector 返回新对象

// ❌ 每次都返回新对象 → 永远触发重渲染
const { messages, isStreaming } = useChatStore((s) => ({
  messages: s.messages,
  isStreaming: s.isStreaming,
}))
 
// ✅ 用 useShallow 做浅比较
import { useShallow } from 'zustand/shallow'
 
const { messages, isStreaming } = useChatStore(
  useShallow((s) => ({ messages: s.messages, isStreaming: s.isStreaming })),
)
 
// ✅ 或者分开订阅(更简单)
const messages = useChatStore((s) => s.messages)
const isStreaming = useChatStore((s) => s.isStreaming)

6.2 在 Server Component 中使用

Zustand Store 是纯客户端的——不能在 Server Component 中调用 useChatStore()。Server Component 需要数据时直接查数据库,不要绕道 Store。

7. 推荐组合

工具管什么典型场景
Context低频全局状态主题、Providers
Zustand高频客户端状态对话流式回复、侧边栏、通知队列
TanStack Query服务端数据缓存列表分页、后台刷新、乐观更新

三者互补,不冲突。一个 AI SaaS 页面可能同时使用三者:TanStack Query 管理对话列表,Zustand 管理当前对话的流式状态,Context 管理主题。

本章小结

  • 选择性订阅是 Zustand 的核心优势——组件只在订阅字段变化时重渲染
  • 中间件组合devtools(调试)+ persist(持久化)+ immer(简化更新),顺序从外到内
  • Slice 模式:超过 10 个字段时按领域拆分,合并导出
  • SSR 兼容useHydrated 延迟显示持久化值 + useRef 一次性初始化服务端数据
  • 常见坑:selector 返回新对象要用 useShallow,不能在 Server Component 中使用
  • 不需要 Provider——任何 Client Component 直接 import 使用

下一章讲 URL State 管理。