Zustand 狀態管理

輕量、無樣板、基於 Hook 的狀態管理方案:Store 建立、Slice 模式、非同步操作、Middleware(persist / devtools)與 TypeScript 整合。

核心概念

Store

透過 create() 建立 store,回傳一個自訂 hook。state 與 action 都定義在同一個地方,不需要額外的 Provider 包裹。

Selector 訂閱

呼叫 hook 時傳入 selector function(如 state => state.count),元件只在所訂閱的值改變時才重新 render,精準避免多餘更新。

Immer 整合(可選)

搭配 immer middleware 後,可在 set 函數中直接 mutate state,不需手動展開物件。

無 Provider

Zustand store 是模組層級的單例(singleton),不需要在 React tree 最外層包裹 Provider,可在任何地方(包含元件外)讀取與更新 state。

Middleware

官方提供 persist(持久化到 localStorage)、devtools(Redux DevTools 支援)等常用 middleware,可自由組合。

subscribe(React 外使用)

透過 useStore.subscribe(listener) 可在元件或 React 之外監聽 state 變化,適合整合第三方函式庫或觸發 side effect。

基本安裝與建立 Store(TypeScript)

安裝

npm install zustand

建立第一個 Store

用 TypeScript interface 定義 state 與 actions 的型別,傳入 create<T>()

// stores/useCounterStore.ts
import { create } from 'zustand'

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

在元件中使用

直接呼叫 hook 並傳入 selector,元件只訂閱需要的欄位,不需要 Provider

// components/Counter.tsx
import { useCounterStore } from '@/stores/useCounterStore'

export default function Counter() {
  // selector:只訂閱 count,action 改變不觸發重渲
  const count = useCounterStore((state) => state.count)
  const { increment, decrement, reset } = useCounterStore()

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

非同步操作

Zustand 不需要特殊的非同步 middleware(如 Thunk)。直接在 action 中使用 async/await,呼叫 set 更新狀態即可。

// stores/useUserStore.ts
import { create } from 'zustand'

interface User { id: number; name: string }

interface UserState {
  users: User[]
  isLoading: boolean
  error: string | null
  fetchUsers: () => Promise<void>
}

export const useUserStore = create<UserState>((set) => ({
  users: [],
  isLoading: false,
  error: null,

  fetchUsers: async () => {
    set({ isLoading: true, error: null })
    try {
      const res = await fetch('/api/users')
      const data: User[] = await res.json()
      set({ users: data, isLoading: false })
    } catch (err) {
      set({ error: (err as Error).message, isLoading: false })
    }
  },
}))

與 Redux Thunk 的比較

Redux 需要 createAsyncThunk + extraReducers 處理 pending / fulfilled / rejected 三種狀態。Zustand 直接在 action 函數中使用 async/await,程式碼量大幅減少

Middleware

persist — 持久化 State

將選定的 state 欄位自動儲存到 localStorage(或自訂 storage),頁面重整後自動還原。

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

interface ThemeState {
  theme: 'light' | 'dark'
  setTheme: (t: 'light' | 'dark') => void
}

export const useThemeStore = create<ThemeState>()(
  persist(
    (set) => ({
      theme: 'light',
      setTheme: (t) => set({ theme: t }),
    }),
    {
      name: 'theme-storage',              // localStorage key
      storage: createJSONStorage(() => localStorage),
      partialize: (state) => ({ theme: state.theme }), // 只持久化 theme
    }
  )
)

devtools — Redux DevTools 整合

包裹 devtools() 後,可在瀏覽器的 Redux DevTools 擴充套件中即時追蹤 state 變化。

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

// 組合多個 middleware:devtools 包外層,persist 包內層
export const useCartStore = create<CartState>()(
  devtools(
    persist(
      (set, get) => ({
        items: [],
        addItem: (item) =>
          set(
            (state) => ({ items: [...state.items, item] }),
            false,
            'cart/addItem'   // ← Action 名稱,顯示在 DevTools
          ),
        total: () => get().items.reduce((sum, i) => sum + i.price, 0),
      }),
      { name: 'cart-storage' }
    ),
    { name: 'CartStore' }    // ← DevTools 中的 store 名稱
  )
)

immer — 直接 Mutate State

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

interface TodoState {
  todos: { id: number; text: string; done: boolean }[]
  toggle: (id: number) => void
}

export const useTodoStore = create<TodoState>()(
  immer((set) => ({
    todos: [],
    toggle: (id) =>
      set((state) => {
        // ✅ 可直接 mutate,Immer 負責產生新物件
        const todo = state.todos.find((t) => t.id === id)
        if (todo) todo.done = !todo.done
      }),
  }))
)

Slice 模式(拆分大型 Store)

當 store 逐漸龐大時,可將不同功能的 state 拆分為獨立的 slice function,再合併成一個 store,概念類似 Redux Toolkit 的 combineReducers

import { create, StateCreator } from 'zustand'

// ── Slice A:計數器 ──
interface CounterSlice {
  count: number
  increment: () => void
}
const createCounterSlice: StateCreator<
  CounterSlice & UserSlice, [], [], CounterSlice
> = (set) => ({
  count: 0,
  increment: () => set((s) => ({ count: s.count + 1 })),
})

// ── Slice B:使用者 ──
interface UserSlice {
  username: string
  setUsername: (name: string) => void
}
const createUserSlice: StateCreator<
  CounterSlice & UserSlice, [], [], UserSlice
> = (set) => ({
  username: '',
  setUsername: (name) => set({ username: name }),
})

// ── 合併 ──
export const useBoundStore = create<CounterSlice & UserSlice>()((...a) => ({
  ...createCounterSlice(...a),
  ...createUserSlice(...a),
}))

在元件外讀寫 State

Zustand store 是普通的 JavaScript 模組,可直接在 API 工具函數、事件處理器或任何非元件的地方存取 state。

import { useAuthStore } from '@/stores/useAuthStore'

// 在 API 攔截器中讀取 token(非 React 環境)
const token = useAuthStore.getState().token

// 在非同步函數中更新 state
async function logout() {
  await fetch('/api/logout', { method: 'POST' })
  useAuthStore.setState({ token: null, user: null })
}

// 訂閱 state 變化(不在 React 內)
const unsubscribe = useAuthStore.subscribe(
  (state) => state.token,
  (token) => {
    if (!token) redirectToLogin()
  }
)

Zustand vs Redux Toolkit 比較

項目ZustandRedux Toolkit
Bundle size~1 KB~47 KB
Provider 需求不需要需要 <Provider>
樣板程式碼極少中等(已被 RTK 簡化)
非同步處理直接 async/awaitcreateAsyncThunk
DevTools 支援需加 devtools middleware內建
持久化persist middlewareredux-persist
學習曲線
適用場景中小型專案、需快速開發大型、多人協作專案

常見陷阱

訂閱整個 store

直接呼叫 useMyStore()(不傳 selector)會訂閱所有欄位,任何更新都觸發重渲。

// ❌ 整個 store,容易過度 re-render
const store = useMyStore()

// ✅ 只訂閱需要的欄位
const count = useMyStore((s) => s.count)

Selector 回傳新物件

每次 render 都建立新物件參考,即使內容未變也會觸發重渲。搭配 useShallow 做淺比較。

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

// ❌ 每次都回傳新物件 → 永遠 re-render
const { a, b } = useStore((s) => ({ a: s.a, b: s.b }))

// ✅ useShallow 做淺比較
const { a, b } = useStore(useShallow((s) => ({ a: s.a, b: s.b })))

SSR 中的 store 共享問題

在 Next.js App Router 的 SSR 環境中,模組層級的 store 會在所有請求間共享,造成跨請求狀態汙染。需使用 createStore + Context 的模式隔離每個請求。

persist hydration 閃爍

使用 persist 時,SSR 階段 store 尚未從 localStorage 還原,會出現短暫不一致(hydration mismatch)。可用 useStore.persist.hasHydrated() 或掛載後才渲染特定 UI 解決。