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 比較
| 項目 | Zustand | Redux Toolkit |
|---|---|---|
| Bundle size | ~1 KB | ~47 KB |
| Provider 需求 | 不需要 | 需要 <Provider> |
| 樣板程式碼 | 極少 | 中等(已被 RTK 簡化) |
| 非同步處理 | 直接 async/await | createAsyncThunk |
| DevTools 支援 | 需加 devtools middleware | 內建 |
| 持久化 | persist middleware | redux-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 解決。