Zustand 深度集成
Context 在高频更新场景性能不佳——任何值变化都触发所有消费者重渲染。Zustand 通过选择性订阅解决这个问题:组件只在自己订阅的字段变化时才重渲染。同时 API 极简(一个
create函数)、不需要 Provider、gzip 后仅 ~1KB。
1. 为什么选 Zustand
| 维度 | Context | Redux | Zustand |
|---|---|---|---|
| 选择性订阅 | ❌ 全量重渲染 | ✅ 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({ isStreaming: true }) 只更新 isStreaming,不会影响 messages 和 currentModel。这一点和 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 包含 { messages, isStreaming, model },AI 流式回复时 messages 每秒更新几十次,ChatInput 也会跟着重渲染几十次(它并不需要 messages)。用 Zustand,ChatInput 只在 isStreaming 变化时重渲染——从 idle 到 streaming,再从 streaming 到 idle,总共 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 的三个核心中间件各解决一个问题:
| 中间件 | 解决什么 | 安装 |
|---|---|---|
devtools | Redux 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>
}只有使用了 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 管理。