Blog

/

Frontend

Zustand

Zustand Redux useContext LLM Zustand

Posted at

2026年3月25日

Posted on

Frontend

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 聊天應用的狀態很複雜,涉及對話歷史、輸入框狀態、生成中標記、模型選擇等。

架構設計

  1. conversations 和 messages 分離。如果把 messages 內嵌在 conversation 裡,新增一條訊息時要拷貝整個對話結構。分離後新增訊息只會改變 messages 這個物件,而 conversations 的參考不變。這樣 ConversationList 訂閱 conversations 時,新增訊息不會觸發它重新渲染。

  2. inputText 和 isGenerating 分開管理。LLM 對話框的狀態改變會非常頻繁,如果和訊息、模型配置放一起,每次輸入都會導致整個 store 有訂閱者的組件重新渲染。而分開後只有 ChatInput 組件訂閱 inputText,其他組件不受影響。

  3. modelConfig 獨立。模型配置改變的頻率遠低於訊息,但改變時不應該影響訊息列表的顯示。分離讓 MessageList 可以完全不訂閱 modelConfig。

import { create } from 'zustand'

const useChatStore = create((set, get) => ({
  // 分離設計:對話和訊息分開,輸入狀態獨立
  conversations: {}, // { id: { id, title, messageIds: [] } }
  messages: {}, // { id: { id, conversationId, role, content } }
  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 useChatStore
conversations 和 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 避免了不必要的重新渲染。

架構設計

  1. typingUsers 用 Set 而不是陣列。打字狀態改變非常頻繁,可能每秒變化多次。用 Set 而不是陣列有兩個優勢:查找 O(1) 速度更快,而且新增或刪除時不需要遍歷整個陣列重新建立新物件。每次用戶開始或停止輸入時,只需要改變 Set 的引用,不需要拷貝整個陣列。

  2. messages 用陣列不用物件。訊息是按時間順序的,需要保持順序。如果用物件,就需要額外的 messageIds 陣列來記錄順序。訊息列表不會經常修改單條訊息,主要是新增,所以陣列更合適。

  3. users 分開管理。用戶的上線、離線、打字狀態變化頻繁,但 MessageList 不需要訂閱用戶狀態。分開後,MessageList 只訂閱 messages,用戶狀態改變不會重新渲染訊息列表。

import { create } from 'zustand'

const useChatRoomStore = create((set, get) => ({
  users: {}, // { userId: { id, name, status: 'online'|'offline' } }
  messages: [], // { id, userId, content }
  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 更適合。

架構設計

  1. cart 作為獨立物件。購物車狀態(items 和 total)會變化的頻繁,但完全獨立於訂單歷史。CartSummary 只訂閱 cart,新增訂單時 cart 被清空,但這只改變 cart 物件的引用,不會觸發訂單列表重新渲染。

  2. filter 和 page 分開。篩選和分頁是 UI 狀態,和真實的訂單數據無關。改變篩選時不應該改變 orders 陣列本身,只影響顯示。分開後,點擊篩選按鈕只改變 filter 和 page,不改變 orders,其他訂閱 orders 的組件不受影響。

  3. 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: [], // { id, status, total, createdAt, items }
    currentOrderId: null,
    
    // 篩選和分頁
    filter: 'all', // 'all', 'pending', 'completed', 'cancelled'
    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 useOrderStore

subscribeWithSelector 中間件可以更細粒度地訂閱特定的狀態片段。

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)
})

// 現在任何訂閱 scrollY 的組件,每秒都會重新渲染 60 次
export function ScrollIndicator() {
  const scrollY = useStore((state) => state.scrollY) // 訂閱 scrollY
  return <div>Scroll: {scrollY}px</div>
}

// 這個組件現在成了性能瓶頸,因為它被迫重新渲染 60 次
export function HeavyComponent() {
  const data = useStore((state) => state.someData) // 不訂閱 scrollY
  
  // 但因為 ScrollIndicator 也在這裡,它的重新渲染會拖累整個組件樹
  return (
    <div>
      <ScrollIndicator />
      <ExpensiveCalculation data={data} />
    </div>
  )
}
解決方法

高頻狀態不應該放在 store 裡。應該用 React 的 ref 或 local state,或者放在單獨的 store 中,並且隔離它的訂閱者。

// 好的做法
const useScrollStore = create((set) => ({
  scrollY: 0,
  updateScroll: (y) => set({ scrollY: y })
}))

// 用 useRef 追蹤最新值,但不觸發重新渲染
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> // 簡單組件,重新渲染快
}

// HeavyComponent 不訂閱 scrollY,不會因為滾動而重新渲染
export function HeavyComponent() {
  const data = useStore((state) => state.someData)
  return (
    <div>
      <ScrollIndicator /> {/* 隔離高頻組件 */}
      <ExpensiveCalculation data={data} />
    </div>
  )
}

誤區 2:把異步邏輯放在組件中而不是 store 中

如果異步邏輯在組件中,每次組件掛載時都會執行一遍。在複雜應用中,同樣的數據可能被多個組件請求,導致重複的 API 呼叫。

// 不好:邏輯分散在組件中
export function UserProfile() {
  const [user, setUser] = useState(null)
  
  useEffect(() => {
    // 每次這個組件掛載,都會請求一次
    fetch('/api/user')
      .then(res => res.json())
      .then(data => setUser(data))
  }, [])
}

export function UserHeader() {
  const [user, setUser] = useState(null)
  
  useEffect(() => {
    // 重複的邏輯!再請求一次
    fetch('/api/user')
      .then(res => res.json())
      .then(data => setUser(data))
  }, [])
}

// 如果同時掛載 UserProfile 和 UserHeader,會發送兩次相同的請求
正確的做法

把非同步邏輯和狀態都放在 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 時才請求
  }, [user, fetchUser])
}

export function UserHeader() {
  const user = useUserStore((state) => state.user)
  const fetchUser = useUserStore((state) => state.fetchUser)
  
  useEffect(() => {
    if (!user) fetchUser() // 共享同一個 user,不會重複請求
  }, [user, fetchUser])
}

// 現在只會發送一次請求,兩個組件共享結果

asyncData 和 loading 狀態在 store 中,任何組件都可以訂閱這些狀態不會出現不同步的情況。fetchUser 邏輯也在 store 中,容易測試和維護。

誤區 3:在 selector 中做複雜計算而沒有最佳化

為什麼會出現性能問題

// 不好:selector 中的複雜計算每次都執行
const expensiveList = useOrderStore((state) => {
  // 這個計算在 store 中任何狀態改變時都會執行
  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]) // 只有 orders 改變時才重新計算
}

但這樣的缺點是每個使用這個邏輯的組件都要寫一遍 useMemo。

解決方法 2:在 store 中用 useShallow 或預計算

// 在 store 中預先計算結果
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 更精細地控制何時重新計算:

// 最佳方案:只在 orders 改變時計算
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)
      }
    })
  }))
)

// 組件訂閱 completedOrdersCache,不需要每次計算
export function OrderList() {
  const completedOrders = useOrderStore((state) => state.completedOrdersCache)
}

最後一個方案最好,把計算推到狀態改變的時刻(在 addOrder 中),而不是讀取的時刻(在 selector 中)。這樣:

  1. 計算只在數據真的改變時執行

  2. 多個組件讀取同一個預計算結果,不重複計算

  3. 不會因為組件數增加而變慢

參考資源