Zustand 是一個輕量級的狀態管理庫,沒有 Redux 的冗長配置,也沒有 useContext 的重新渲染困擾。在實作 LLM 聊天框、多人實時聊天室、中型電商系統設計都適合用 Zustand。
特性 | Zustand | Redux Toolkit | useContext | Recoil |
|---|
非同步操作 | 簡單 | Middleware 完善 | 需自行處理 | 有 |
DevTools 支援 | 一般 | 優秀 | 無 | 無 |
適用規模 | 中型(100-500) | 大型(500+) | 小型(<100) | 中型 |
適合場景 | LLM、聊天、快速開發 | POS、交易所、金融 | 簡單全局 | 細粒度 |
核心概念
Zustand 的核心是 create 函數,一個 store 包含狀態和修改狀態的方法。
import { create } from 'zustand'
const useStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 }))
}))然後在任何組件中用 Hook 的方式讀取和修改狀態,不需要 Provider 包裹,也不會有多餘的重新渲染。
實例 1:LLM 聊天框
LLM 聊天應用的狀態很複雜,涉及對話歷史、輸入框狀態、生成中標記、模型選擇等。
架構設計
conversations 和 messages 分離。如果把 messages 內嵌在 conversation 裡,新增一條訊息時要拷貝整個對話結構。分離後新增訊息只會改變 messages 這個物件,而 conversations 的參考不變。這樣 ConversationList 訂閱 conversations 時,新增訊息不會觸發它重新渲染。
inputText 和 isGenerating 分開管理。LLM 對話框的狀態改變會非常頻繁,如果和訊息、模型配置放一起,每次輸入都會導致整個 store 有訂閱者的組件重新渲染。而分開後只有 ChatInput 組件訂閱 inputText,其他組件不受影響。
modelConfig 獨立。模型配置改變的頻率遠低於訊息,但改變時不應該影響訊息列表的顯示。分離讓 MessageList 可以完全不訂閱 modelConfig。
import { create } from 'zustand'
const useChatStore = create((set, get) => ({
conversations: {},
messages: {},
currentConversationId: null,
inputText: '',
isGenerating: false,
setInputText: (text) => set({ inputText: text }),
createConversation: () => {
const id = Date.now().toString()
set((state) => ({
conversations: {
...state.conversations,
[id]: { id, title: 'New Chat', messageIds: [] }
},
currentConversationId: id
}))
},
addMessage: (role, content) => {
const msgId = Date.now().toString()
const convId = get().currentConversationId
set((state) => ({
messages: {
...state.messages,
[msgId]: { id: msgId, conversationId: convId, role, content }
},
conversations: {
...state.conversations,
[convId]: {
...state.conversations[convId],
messageIds: [...state.conversations[convId].messageIds, msgId]
}
}
}))
},
startGeneration: () => set({ isGenerating: true }),
finishGeneration: (text) => {
get().addMessage('assistant', text)
set({ isGenerating: false })
}
}))
export default useChatStoreconversations 和 messages 分離的好處
假設 ConversationList 組件只訂閱 conversations,MessageList 只訂閱 messages。當 user 輸入新訊息時,addMessage 只會改變 messages 這個物件的引用。但 conversations 的引用沒變,所以 ConversationList 不會重新渲染,只有 MessageList 重新渲染。如果把訊息內嵌在 conversation 裡,新增訊息時要建立新的 conversations 物件,ConversationList 就會不必要地重新渲染。
為什麼 inputText、isGenerating、generatedText 要分開
輸入框每改變一次,如果 inputText 和 modelConfig 在同一個物件裡,改變 inputText 就會改變整個物件的引用,訂閱 modelConfig 的組件也會重新渲染。但分開後,ModelConfigPanel 完全不受輸入框改變的影響。
isGenerating 和 generatedText 分開
生成過程中,generatedText 每秒改變多次(Streaming 推送),但 isGenerating 只在開始和結束時改變。如果放一起,ChatInput 的 submit 按鈕(只訂閱 isGenerating)就會因為 generatedText 的變化而不必要地重新渲染。
在組件中使用時,Zustand 會自動幫你細粒度訂閱。ChatInput 只會在 inputText 改變時重新渲染,不會因為訊息列表改變而重新渲染:
'use client'
import { useEffect } from 'react'
import useChatStore from './chatStore'
export function ChatInput() {
const inputText = useChatStore((state) => state.inputText)
const setInputText = useChatStore((state) => state.setInputText)
const isGenerating = useChatStore((state) => state.isGenerating)
const modelConfig = useChatStore((state) => state.modelConfig)
const addMessage = useChatStore((state) => state.addMessage)
const startGeneration = useChatStore((state) => state.startGeneration)
const appendGeneratedText = useChatStore((state) => state.appendGeneratedText)
const finishGeneration = useChatStore((state) => state.finishGeneration)
const handleSubmit = async () => {
if (!inputText.trim()) return
addMessage('user', inputText, 0)
setInputText('')
startGeneration()
try {
const response = await fetch('/api/chat', {
method: 'POST',
body: JSON.stringify({
message: inputText,
config: modelConfig
})
})
const reader = response.body.getReader()
const decoder = new TextDecoder()
while (true) {
const { done, value } = await reader.read()
if (done) break
const chunk = decoder.decode(value)
appendGeneratedText(chunk)
}
finishGeneration()
} catch (error) {
console.error('Generation failed:', error)
}
}
return (
<div>
<textarea
value={inputText}
onChange={(e) => setInputText(e.target.value)}
placeholder="輸入訊息..."
disabled={isGenerating}
/>
<button onClick={handleSubmit} disabled={isGenerating}>
{isGenerating ? '生成中...' : '發送'}
</button>
</div>
)
}
export function ConversationList() {
const conversations = useChatStore((state) => state.conversations)
const currentConversationId = useChatStore((state) => state.currentConversationId)
const setCurrentConversation = useChatStore((state) => state.setCurrentConversation)
const createConversation = useChatStore((state) => state.createConversation)
return (
<div>
<button onClick={() => createConversation()}>新增會話</button>
<ul>
{Object.values(conversations).map(conv => (
<li
key={conv.id}
onClick={() => setCurrentConversation(conv.id)}
style={{
fontWeight: currentConversationId === conv.id ? 'bold' : 'normal'
}}
>
{conv.title}
</li>
))}
</ul>
</div>
)
}
export function ChatMessages() {
const conversations = useChatStore((state) => state.conversations)
const messages = useChatStore((state) => state.messages)
const currentConversationId = useChatStore((state) => state.currentConversationId)
const currentConv = conversations[currentConversationId]
if (!currentConv) return null
return (
<div>
{currentConv.messages.map(msgId => {
const msg = messages[msgId]
return (
<div key={msgId} style={{ marginBottom: '1rem' }}>
<strong>{msg.role === 'user' ? '你:' : 'AI:'}</strong>
<p>{msg.content}</p>
</div>
)
})}
</div>
)
}Zustand 自動選擇只有你訂閱的狀態改變時他才會去重新渲染,而 ChatInput 只訂閱 inputText 和 isGenerating,當 conversations 改變時不會重新渲染。
實例 2:多人實時聊天室
在多人聊天室中,需要管理用戶列表、聊天訊息、打字狀態、在線狀態。這種 real time 變化頻繁,用 Zustand 避免了不必要的重新渲染。
架構設計
typingUsers 用 Set 而不是陣列。打字狀態改變非常頻繁,可能每秒變化多次。用 Set 而不是陣列有兩個優勢:查找 O(1) 速度更快,而且新增或刪除時不需要遍歷整個陣列重新建立新物件。每次用戶開始或停止輸入時,只需要改變 Set 的引用,不需要拷貝整個陣列。
messages 用陣列不用物件。訊息是按時間順序的,需要保持順序。如果用物件,就需要額外的 messageIds 陣列來記錄順序。訊息列表不會經常修改單條訊息,主要是新增,所以陣列更合適。
users 分開管理。用戶的上線、離線、打字狀態變化頻繁,但 MessageList 不需要訂閱用戶狀態。分開後,MessageList 只訂閱 messages,用戶狀態改變不會重新渲染訊息列表。
import { create } from 'zustand'
const useChatRoomStore = create((set, get) => ({
users: {},
messages: [],
typingUsers: new Set(),
addUser: (userId, name) => set((state) => ({
users: { ...state.users, [userId]: { id: userId, name, status: 'online' } }
})),
removeUser: (userId) => set((state) => ({
users: { ...state.users, [userId]: { ...state.users[userId], status: 'offline' } }
})),
addMessage: (userId, content) => set((state) => ({
messages: [...state.messages, { id: Date.now().toString(), userId, content }]
})),
setUserTyping: (userId, isTyping) => set((state) => {
const newTyping = new Set(state.typingUsers)
if (isTyping) newTyping.add(userId)
else newTyping.delete(userId)
return { typingUsers: newTyping }
})
}))
export default useChatRoomStore打字的過程 state 改變會非常頻繁,如果選型選用陣列,每次都要經歷重建。Set 的新增刪除是 O(1),在 100 人聊天室中快 10 倍。
在組件中,細粒度訂閱避免了不必要的重新渲染:
export function MessageList() {
const messages = useChatRoomStore((state) => state.messages)
const users = useChatRoomStore((state) => state.users)
return (
<div>
{messages.map(msg => (
<div key={msg.id}>
<strong>{users[msg.userId]?.name}:</strong>
<p>{msg.content}</p>
</div>
))}
</div>
)
}
export function UserList() {
const users = useChatRoomStore((state) => state.users)
const typingUsers = useChatRoomStore((state) => state.typingUsers)
return (
<div>
{Object.values(users).map(user => (
<div key={user.id} style={{ opacity: user.status === 'online' ? 1 : 0.5 }}>
<span>{user.name}</span>
{typingUsers.has(user.id) && <span>輸入中...</span>}
</div>
))}
</div>
)
}
export function TypingIndicator() {
const typingUsers = useChatRoomStore((state) => state.typingUsers)
const users = useChatRoomStore((state) => state.users)
if (typingUsers.size === 0) return null
const names = Array.from(typingUsers)
.map(id => users[id]?.name)
.join('、')
return <p>{names} 正在輸入...</p>
}MessageList 只訂閱 messages,改變 typingUsers 時不會重新渲染。UserList 只訂閱 users 和 typingUsers,新增訊息時不會重新渲染。
實例 3:電商的訂單狀態
一個中型電商平台可能有幾百個組件訂閱訂單狀態。Redux 雖然也適合但會太肥,Zustand 更適合。
架構設計
cart 作為獨立物件。購物車狀態(items 和 total)會變化的頻繁,但完全獨立於訂單歷史。CartSummary 只訂閱 cart,新增訂單時 cart 被清空,但這只改變 cart 物件的引用,不會觸發訂單列表重新渲染。
filter 和 page 分開。篩選和分頁是 UI 狀態,和真實的訂單數據無關。改變篩選時不應該改變 orders 陣列本身,只影響顯示。分開後,點擊篩選按鈕只改變 filter 和 page,不改變 orders,其他訂閱 orders 的組件不受影響。
currentOrderId 的作用。點擊某個訂單時設定 currentOrderId,可以讓 OrderDetail 組件訂閱 currentOrderId 和 orders,只有選中的訂單才會詳細顯示。避免了所有訂單都被詳細渲染。
import { create } from 'zustand'
import { subscribeWithSelector } from 'zustand/react'
const useOrderStore = create(
subscribeWithSelector((set, get) => ({
cart: {
items: [],
total: 0
},
orders: [],
currentOrderId: null,
filter: 'all',
page: 1,
pageSize: 20,
addToCart: (product) => set((state) => {
const existing = state.cart.items.find(item => item.id === product.id)
if (existing) {
return {
cart: {
items: state.cart.items.map(item =>
item.id === product.id
? { ...item, quantity: item.quantity + 1 }
: item
),
total: state.cart.total + product.price
}
}
}
return {
cart: {
items: [...state.cart.items, { ...product, quantity: 1 }],
total: state.cart.total + product.price
}
}
}),
removeFromCart: (productId) => set((state) => {
const item = state.cart.items.find(i => i.id === productId)
return {
cart: {
items: state.cart.items.filter(i => i.id !== productId),
total: state.cart.total - (item ? item.price * item.quantity : 0)
}
}
}),
clearCart: () => set({ cart: { items: [], total: 0 } }),
createOrder: async () => {
const state = get()
const { cart } = state
if (cart.items.length === 0) return
try {
const res = await fetch('/api/orders', {
method: 'POST',
body: JSON.stringify({ items: cart.items, total: cart.total })
})
const order = await res.json()
set((state) => ({
orders: [order, ...state.orders],
currentOrderId: order.id,
cart: { items: [], total: 0 }
}))
return order
} catch (error) {
console.error('Failed to create order:', error)
}
},
updateOrderStatus: (orderId, status) => set((state) => ({
orders: state.orders.map(order =>
order.id === orderId ? { ...order, status } : order
)
})),
setFilter: (filter) => set({ filter, page: 1 }),
setPage: (page) => set({ page })
}))
)
export default useOrderStoresubscribeWithSelector 中間件可以更細粒度地訂閱特定的狀態片段。
export function CartSummary() {
const cartTotal = useOrderStore((state) => state.cart.total)
const itemCount = useOrderStore((state) => state.cart.items.length)
return (
<div>
<p>購物車:{itemCount} 件</p>
<p>合計:${cartTotal}</p>
</div>
)
}
export function OrderHistory() {
const filteredOrders = useOrderStore((state) => {
const { orders, filter, page, pageSize } = state
let filtered = orders
if (filter !== 'all') {
filtered = orders.filter(order => order.status === filter)
}
return filtered.slice((page - 1) * pageSize, page * pageSize)
})
return (
<table>
<tbody>
{filteredOrders.map(order => (
<tr key={order.id}>
<td>{order.id}</td>
<td>${order.total}</td>
<td>{order.status}</td>
</tr>
))}
</tbody>
</table>
)
}中間件系統
Zustand 的中間件讓你攔截狀態改變,用於日誌、持久化、journey 等。
const useLoggedStore = create(
(set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 }))
}),
(config) => (set, get, api) => {
return config(
(args) => {
console.log('State update:', args)
set(args)
},
get,
api
)
}
)持久化
Zustand 可以自動把狀態存到 localStorage,重新整理頁面時恢復。
import { persist } from 'zustand/middleware'
const useStore = create(
persist(
(set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 }))
}),
{
name: 'app-storage'
}
)
)非同步操作
Zustand 中的非同步操作很簡單,直接在 action 中 await。
const useUserStore = create((set) => ({
user: null,
loading: false,
fetchUser: async (userId) => {
set({ loading: true })
try {
const res = await fetch(`/api/users/${userId}`)
const user = await res.json()
set({ user, loading: false })
} catch (error) {
set({ loading: false })
}
}
}))常見誤區
誤區 1:狀態變化太頻繁導致性能問題
Zustand 可以管理 State 沒錯,但如果狀態改變太頻繁,Zustand 也無法避免大量重新渲染。
假設你在 store 中有一個 scrollY 狀態,記錄頁面滾動位置。用戶滾動時,scrollY 每幾毫秒改變一次,一秒可能改變 60 次。
const useStore = create((set) => ({
scrollY: 0,
updateScroll: (y) => set({ scrollY: y })
}))
window.addEventListener('scroll', () => {
useStore.getState().updateScroll(window.scrollY)
})
export function ScrollIndicator() {
const scrollY = useStore((state) => state.scrollY)
return <div>Scroll: {scrollY}px</div>
}
export function HeavyComponent() {
const data = useStore((state) => state.someData)
return (
<div>
<ScrollIndicator />
<ExpensiveCalculation data={data} />
</div>
)
}解決方法
高頻狀態不應該放在 store 裡。應該用 React 的 ref 或 local state,或者放在單獨的 store 中,並且隔離它的訂閱者。
const useScrollStore = create((set) => ({
scrollY: 0,
updateScroll: (y) => set({ scrollY: y })
}))
const scrollYRef = useRef(0)
window.addEventListener('scroll', () => {
scrollYRef.current = window.scrollY
useScrollStore.getState().updateScroll(window.scrollY)
})
export function ScrollIndicator() {
const scrollY = useScrollStore((state) => state.scrollY)
return <div style={{ fontSize: '12px' }}>Scroll: {scrollY}px</div>
}
export function HeavyComponent() {
const data = useStore((state) => state.someData)
return (
<div>
<ScrollIndicator /> {}
<ExpensiveCalculation data={data} />
</div>
)
}誤區 2:把異步邏輯放在組件中而不是 store 中
如果異步邏輯在組件中,每次組件掛載時都會執行一遍。在複雜應用中,同樣的數據可能被多個組件請求,導致重複的 API 呼叫。
正確的做法
把非同步邏輯和狀態都放在 store 中,組件只負責呼叫和讀取。
const useUserStore = create((set) => ({
user: null,
loading: false,
fetchUser: async () => {
set({ loading: true })
try {
const res = await fetch('/api/user')
const user = await res.json()
set({ user, loading: false })
} catch (error) {
console.error(error)
set({ loading: false })
}
}
}))
export function UserProfile() {
const user = useUserStore((state) => state.user)
const fetchUser = useUserStore((state) => state.fetchUser)
useEffect(() => {
if (!user) fetchUser()
}, [user, fetchUser])
}
export function UserHeader() {
const user = useUserStore((state) => state.user)
const fetchUser = useUserStore((state) => state.fetchUser)
useEffect(() => {
if (!user) fetchUser()
}, [user, fetchUser])
}
asyncData 和 loading 狀態在 store 中,任何組件都可以訂閱這些狀態不會出現不同步的情況。fetchUser 邏輯也在 store 中,容易測試和維護。
誤區 3:在 selector 中做複雜計算而沒有最佳化
為什麼會出現性能問題
const expensiveList = useOrderStore((state) => {
return state.orders
.filter(order => order.status === 'completed')
.map(order => ({
...order,
displayName: `Order ${order.id} - ${order.total}`
}))
.sort((a, b) => b.total - a.total)
.slice(0, 10)
})假設有 1000 個訂單,每次 store 改變這個複雜計算都會執行一次。如果 user 在輸入框打字,store 改變 100 次,複雜計算就執行 100 次。
解決方法 1:用 useMemo
export function OrderList() {
const orders = useOrderStore((state) => state.orders)
const expensiveList = useMemo(() => {
return orders
.filter(order => order.status === 'completed')
.sort((a, b) => b.total - a.total)
.slice(0, 10)
}, [orders])
}但這樣的缺點是每個使用這個邏輯的組件都要寫一遍 useMemo。
解決方法 2:在 store 中用 useShallow 或預計算
const useOrderStore = create(
subscribeWithSelector((set, get) => ({
orders: [],
getCompletedOrders: () => {
const { orders } = get()
return orders
.filter(order => order.status === 'completed')
.sort((a, b) => b.total - a.total)
.slice(0, 10)
}
}))
)
export function OrderList() {
const completedOrders = useOrderStore((state) =>
state.getCompletedOrders()
)
}或者用 subscribeWithSelector 更精細地控制何時重新計算:
const useOrderStore = create(
subscribeWithSelector((set, get) => ({
orders: [],
completedOrdersCache: [],
addOrder: (order) => set((state) => {
const newOrders = [...state.orders, order]
return {
orders: newOrders,
completedOrdersCache: newOrders
.filter(o => o.status === 'completed')
.sort((a, b) => b.total - a.total)
.slice(0, 10)
}
})
}))
)
export function OrderList() {
const completedOrders = useOrderStore((state) => state.completedOrdersCache)
}最後一個方案最好,把計算推到狀態改變的時刻(在 addOrder 中),而不是讀取的時刻(在 selector 中)。這樣:
計算只在數據真的改變時執行
多個組件讀取同一個預計算結果,不重複計算
不會因為組件數增加而變慢
參考資源