2026/07/07
Zustand 是一個很好用的狀態管理工具。
它 API 簡單、樣板少、學習成本低,也不像 Redux 那樣需要寫一堆 action、reducer、selector。Zustand 官方也把它定位成一個 small、fast、scalable 的狀態管理方案,特色是 API 輕量、不強迫使用 Provider,也有 persist、devtools、immer 等 middleware 可以搭配使用。
但 Zustand 最容易出問題的地方,也剛好是它最大的優點:
它太好用了,所以我們很容易把所有東西都塞進 Store。
一開始只是放個 sidebar 開關。
後來 API response 也放進去。 Loading 狀態也放進去。 Error 狀態也放進去。 表單資料也放進去。 登入者資料也放進去。 Modal 狀態也放進去。 甚至連某個頁面自己的 filter、pagination、tab,也全部放進去。
最後 Zustand 不是幫你管理狀態,而是變成另一個難以追蹤的全域垃圾場。
這篇想整理幾個我認為常見的 Zustand anti-patterns,以及哪些狀態其實不該放進 Store。
我會把 Zustand 定位成:
管理跨 Component、跨頁面,且真的屬於 Client State 的資料。
比較適合放進 Zustand 的狀態:
| 狀態類型 | 適合程度 | 範例 |
|---|---|---|
| UI 偏好 | 高 | theme、sidebar collapsed |
| 跨頁面流程狀態 | 高 | checkout step、wizard step |
| Client-only 狀態 | 高 | command palette open、drawer state |
| 輕量共享狀態 | 中 | selected item、global filter |
| Server State | 低 | API response、user profile、list data |
| 表單輸入 | 低 | react-hook-form 能處理就不要全域化 |
| 敏感資料 | 極低 | token、完整 user、payment provider id |
Zustand 不是不能處理複雜狀態,而是你要先確認:
這個狀態真的需要全域共享嗎?
如果答案是否定的,那它很可能不該進 Store。
這是最常見的錯誤。
例如:
1type ProductStore = {
2 products: Product[]
3 isLoading: boolean
4 error: string | null
5 fetchProducts: () => Promise<void>
6}
7
8export const useProductStore = create<ProductStore>((set) => ({
9 products: [],
10 isLoading: false,
11 error: null,
12
13 fetchProducts: async () => {
14 set({ isLoading: true, error: null })
15
16 try {
17 const res = await fetch('/api/products')
18 const products = await res.json()
19
20 set({ products, isLoading: false })
21 } catch {
22 set({ error: 'Failed to fetch products', isLoading: false })
23 }
24 },
25}))這看起來很直覺,但問題很多。
你很快就會需要自己處理:
| 問題 | 你要自己處理什麼 |
|---|---|
| 快取 | 什麼時候資料算新?什麼時候要重新抓? |
| loading | 首次 loading、背景更新、重新驗證狀態 |
| error | 錯誤重試、錯誤重置、錯誤顯示 |
| invalidation | 新增、編輯、刪除後哪些資料要更新? |
| race condition | 多個 request 回來順序不同怎麼辦? |
| pagination | 分頁資料要合併還是覆蓋? |
| optimistic update | 失敗時要 rollback 嗎? |
這些其實都是 Server State 的問題。
如果資料來源是 server,而且你需要 cache、refetch、invalidation、retry,那通常應該優先考慮 TanStack Query,而不是把 API 流程全部塞進 Zustand。
比較好的分工是:
1function useProducts() {
2 return useQuery({
3 queryKey: ['products'],
4 queryFn: getProducts,
5 })
6}Zustand 則只保存真正的 client state:
1type ProductUiStore = {
2 selectedProductId: string | null
3 setSelectedProductId: (id: string | null) => void
4}
5
6export const useProductUiStore = create<ProductUiStore>((set) => ({
7 selectedProductId: null,
8 setSelectedProductId: (id) => set({ selectedProductId: id }),
9}))這樣分工會清楚很多:
| 狀態 | 負責工具 |
|---|---|
| 商品列表資料 | TanStack Query |
| 商品 cache / refetch | TanStack Query |
| 選中的商品 ID | Zustand |
| Drawer 開關 | Zustand |
重點是:
Zustand 可以存資料,但不代表它應該接管 server state 的生命週期。
另一種常見情況是,把 Zustand 當成前端版 database。
例如:
1type UserStore = {
2 users: User[]
3 addUser: (user: User) => void
4 updateUser: (id: string, payload: Partial<User>) => void
5 deleteUser: (id: string) => void
6}這在小 demo 裡沒問題,但在真實產品裡很容易失控。
因為你的真正資料來源其實在 server 或 database。Zustand 裡的資料只是一份 client-side copy。
一旦有多人操作、跨分頁、權限變化、server validation、資料同步問題,你就要開始處理:
| 問題 | 風險 |
|---|---|
| Client store 跟 DB 不一致 | 使用者看到舊資料 |
| 權限已變更但 store 沒更新 | 顯示不該看的功能 |
| mutation 失敗但 store 已更新 | UI 誤導使用者 |
| 多個頁面各自改 store | 資料來源混亂 |
| 重新整理後資料消失 | 狀態生命週期不明確 |
Zustand 很適合管理「目前使用者在這個瀏覽器裡的互動狀態」。
但它不應該變成:
前端自己維護一份看起來像真的資料庫的東西。
如果資料的真相在 server,就讓 server state 工具處理它。
很多人會把表單資料放進 Zustand:
1type ProfileFormStore = {
2 name: string
3 email: string
4 bio: string
5 setName: (name: string) => void
6 setEmail: (email: string) => void
7 setBio: (bio: string) => void
8}如果只是單頁表單,這通常沒有必要。
表單狀態本來就有很明確的生命週期:
這種狀態放在 component local state 或 react-hook-form 裡就好。
比較適合放進 Zustand 的,是跨頁面、跨步驟的流程狀態。
例如:
| 情境 | 建議 |
|---|---|
| 單頁登入表單 | 不需要 Zustand |
| 單頁會員資料編輯 | 不需要 Zustand |
| Modal 裡的小表單 | 不需要 Zustand |
| 多步驟註冊流程 | 可以考慮 Zustand |
| 結帳流程跨多個頁面 | 可以考慮 Zustand |
| 複雜 wizard,離開再回來要保留 | 可以考慮 Zustand |
簡單判斷:
如果這個表單狀態只活在一個頁面裡,它通常不需要進 Store。
很多專案會有這種 store:
1type UiStore = {
2 isLoading: boolean
3 errorMessage: string | null
4 isConfirmModalOpen: boolean
5 isEditModalOpen: boolean
6 isDeleteModalOpen: boolean
7}一開始看起來很方便,後來會變得很痛苦。
因為不是所有 loading 都是同一種 loading。 不是所有 error 都應該用同一個 error state。 也不是所有 modal 都需要變成 global modal。
例如:
| 狀態 | 是否該全域化 |
|---|---|
| 整個 App 初始化 loading | 可以 |
| 某個按鈕 submit loading | 不需要 |
| 某個列表 fetch error | 不需要 |
| 全站 toast message | 可以 |
| 某個頁面的 edit modal | 通常不需要 |
| Command palette open | 可以 |
| Sidebar collapsed | 可以 |
最糟的情況是,多個畫面共用同一個 isLoading,結果 A 頁面的 loading 影響 B 頁面的 UI。
1set({ isLoading: true })這行程式碼看起來很簡單,但它沒有語意。
你不知道它代表:
如果真的需要 global loading,也應該有明確語意:
1type GlobalUiStore = {
2 isAppInitializing: boolean
3 isCommandPaletteOpen: boolean
4 isSidebarCollapsed: boolean
5}不要把所有 UI 狀態都塞進一個巨大的 uiStore。
Zustand 的 persist middleware 很方便,可以把狀態保存到 localStorage、sessionStorage 或其他 storage。官方文件也提供 partial persistence 的做法,讓你只保存部分 state,而不是整個 store。
但 persist 很容易被濫用。
例如:
1export const useAuthStore = create<AuthStore>()(
2 persist(
3 (set) => ({
4 user: null,
5 accessToken: null,
6 setUser: (user) => set({ user }),
7 setAccessToken: (accessToken) => set({ accessToken }),
8 }),
9 {
10 name: 'auth-storage',
11 }
12 )
13)這種做法要很小心。
尤其是:
| 資料 | 建議 |
|---|---|
| access token | 不建議放 localStorage |
| refresh token | 更不建議 |
| 完整 user object | 不建議 |
| 權限資料 | 不建議長期相信 client storage |
| theme | 適合 |
| sidebar collapsed | 適合 |
| draft 狀態 | 視情況 |
| 最近選擇的 tab | 可以 |
persist 的問題不是「會不會保存成功」,而是:
這份資料重新打開瀏覽器後,還值得被信任嗎?
Client storage 裡的資料本質上是舊的,而且可以被使用者修改。 所以它適合保存偏好,不適合保存權限。
比較好的做法是只 persist 非敏感、可被覆蓋的 UI preference:
1type PreferenceStore = {
2 theme: 'light' | 'dark' | 'system'
3 isSidebarCollapsed: boolean
4 setTheme: (theme: PreferenceStore['theme']) => void
5 setSidebarCollapsed: (collapsed: boolean) => void
6}
7
8export const usePreferenceStore = create<PreferenceStore>()(
9 persist(
10 (set) => ({
11 theme: 'system',
12 isSidebarCollapsed: false,
13 setTheme: (theme) => set({ theme }),
14 setSidebarCollapsed: (collapsed) =>
15 set({ isSidebarCollapsed: collapsed }),
16 }),
17 {
18 name: 'preference-storage',
19 partialize: (state) => ({
20 theme: state.theme,
21 isSidebarCollapsed: state.isSidebarCollapsed,
22 }),
23 }
24 )
25)重點是使用 partialize 控制要保存哪些欄位,而不是整包 store 直接丟進 storage。
如果你在 Next.js App Router 使用 Zustand,要特別注意 Server Component 與 Client Component 的邊界。
Zustand 官方 Next.js guide 明確提醒:React Server Components 不應該讀取或寫入 Zustand store。原因是 RSC 不能使用 hooks 或 context,而且它們本來就不是設計來管理 state 的;在 Server Component 讀寫 global store,也會違反 Next.js 的架構。
所以這種方向本身就不該做:
1// Server Component
2import { useAppStore } from '@/stores/app-store'
3
4export default function Page() {
5 const user = useAppStore.getState().user
6
7 return <div>{user?.name}</div>
8}這不是「不推薦」而已,而是架構上就不該這樣用。
在 App Router 裡,Server Component 應該負責從 server-side source 取得資料,例如 database、API、session、DAL,然後把需要的資料透過 props 傳給 Client Component。
1// Server Component
2import { getUserProfile } from '@/lib/dal/user'
3import { UserPanel } from './user-panel'
4
5export default async function Page() {
6 const user = await getUserProfile()
7
8 return <UserPanel user={user} />
9}1// Client Component
2'use client'
3
4import { usePreferenceStore } from '@/stores/preference-store'
5
6export function UserPanel({
7 user,
8}: {
9 user: {
10 id: string
11 name: string
12 }
13}) {
14 const isSidebarCollapsed = usePreferenceStore(
15 (state) => state.isSidebarCollapsed
16 )
17
18 return (
19 <div>
20 <p>{user.name}</p>
21 <p>Sidebar: {isSidebarCollapsed ? 'collapsed' : 'expanded'}</p>
22 </div>
23 )
24}比較好的分工是:
| 資料類型 | 建議位置 |
|---|---|
| DB 資料 | Server Component / DAL |
| Session / 權限 | Server-side auth |
| API cache | TanStack Query 或 Server Component |
| Client UI state | Zustand |
| 使用者偏好 | Zustand + persist |
| Server 傳下來的初始資料 | props 傳給 Client Component |
| React Server Component | 不讀寫 Zustand store |
重點是:
Zustand 可以用在 App Router,但它應該待在 Client Component 的世界裡。 Server Component 要相信 server-side data source,不要去碰 client store。
Zustand 用起來很直覺:
1const store = useDashboardStore()但這通常不是好習慣。
因為 component 會訂閱整個 store,只要 store 裡任何狀態改變,都可能造成 re-render。
比較好的做法是只訂閱 component 真正需要的 state:
1const selectedId = useDashboardStore((state) => state.selectedId)如果只是要呼叫 action,而且這個 component 不需要因為該 action 重新 render,可以直接透過 getState() 取得 action:
1function SelectButton({ id }: { id: string }) {
2 return (
3 <button
4 onClick={() => {
5 useDashboardStore.getState().setSelectedId(id)
6 }}
7 >
8 Select
9 </button>
10 )
11}這樣 component 不會因為訂閱 action 而參與 Zustand 的 selector 計算。
但要注意:
1const selectedId = useDashboardStore.getState().selectedId這種寫法不適合拿來讀畫面要顯示的狀態,因為它不是 reactive value。Store 更新時,component 不會因為這個值變了而重新 render。
簡單分法:
| 用途 | 建議寫法 |
|---|---|
| 畫面需要根據 state 更新 | useStore((state) => state.value) |
| 只是事件中呼叫 action | useStore.getState().action() |
| React 外部邏輯讀取目前 state | store.getState() |
| 想訂閱變化並觸發 render | 用 hook selector |
所以不是 getState() 比 hook 高級,而是用途不同。
需要 re-render 的資料,用 selector。 不需要 re-render 的 action,可以用 getState()。有些專案會做一個超大的 global store:
1type AppStore = {
2 user: User | null
3 products: Product[]
4 cart: CartItem[]
5 sidebarOpen: boolean
6 modalOpen: boolean
7 filters: FilterState
8 currentStep: number
9 error: string | null
10}這種 store 一開始很方便,後來會很難維護。
問題包含:
| 問題 | 後果 |
|---|---|
| 責任不清楚 | 不知道狀態屬於哪個 domain |
| action 命名衝突 | reset, clear, setData 到處都是 |
| 測試困難 | 每個測試都要準備巨大 initial state |
| re-render 範圍變大 | 小狀態變動影響大範圍 component |
| persist 難控制 | 很容易不小心保存過多資料 |
比較好的做法是依照 domain 或使用情境拆分:
1usePreferenceStore
2useLayoutStore
3useCheckoutStore
4useCommandPaletteStore但也不要走到另一個極端:每個小狀態都開一個 store。
我的判斷是:
如果這些狀態會一起被使用、一起 reset、一起測試,它們可以在同一個 store。 如果它們的生命週期完全不同,就不要硬塞在一起。
Zustand store 是全域 client state,所以你要明確設計什麼時候 reset。
尤其是:
如果沒有 reset,很容易出現上一個使用者或上一個流程的狀態殘留。
例如:
1type CheckoutStore = {
2 step: number
3 selectedPlanId: string | null
4 couponCode: string
5 reset: () => void
6}
7
8const initialState = {
9 step: 1,
10 selectedPlanId: null,
11 couponCode: '',
12}
13
14export const useCheckoutStore = create<CheckoutStore>((set) => ({
15 ...initialState,
16 reset: () => set(initialState),
17}))我通常會建議每個重要 store 都要有明確的 initial state 和 reset action。
不要只會 set,不會清。
在決定一個狀態要不要放 Zustand 前,我會問這幾個問題:
| 問題 | 如果答案是「否」 |
|---|---|
| 這個狀態會被多個 component 使用嗎? | 不要放 Zustand |
| 這個狀態需要跨頁面保留嗎? | 不一定需要 Zustand |
| 這個狀態是 client-only 嗎? | 如果不是,先考慮 server state 工具 |
| 這個狀態重新整理後還需要存在嗎? | 如果需要,再考慮 persist |
| 這個狀態包含敏感資訊嗎? | 不要 persist,也不要依賴 client state |
| 這個狀態有明確 reset 時機嗎? | 沒有就先不要全域化 |
| 這個狀態是不是只有某個表單用? | 用 local state 或表單工具即可 |
我會把狀態大致分成這樣:
| 狀態 | 建議位置 |
|---|---|
| API response | TanStack Query / Server Component |
| DB 資料 | Server-side / DAL |
| 登入 session | Server-side auth |
| 表單輸入 | react-hook-form / local state |
| 單一 component 狀態 | local state |
| 跨頁 UI 狀態 | Zustand |
| 使用者偏好 | Zustand + persist |
| 權限資料 | Server-side authorization |
| Toast | Zustand 或專用 toast lib |
| Modal | 視情況,通常先 local |
我認為 Zustand 最適合扮演這種角色:
1Server State
2 ├─ Server Component
3 ├─ TanStack Query
4 └─ DAL / API
5
6Client State
7 ├─ local state
8 ├─ react-hook-form
9 └─ Zustand
10
11Persistent Preference
12 └─ Zustand persist也就是:
Zustand 的問題通常不是 API 不好用。
剛好相反。
Zustand 的問題是它太好用,導致我們太容易把不該全域化的狀態全域化。
如果一個狀態不需要共享,就讓它留在 component 裡。 如果一份資料來自 server,就讓 server state 工具處理它。 如果一個資料牽涉權限,就不要相信 client store。 如果一個狀態需要長期保存,就先確認它是否真的適合 persist。
狀態管理最重要的不是「放在哪裡比較方便」,而是:
這個狀態的來源、生命週期、責任邊界,到底是什麼?
想清楚這件事,Zustand 才會是讓專案變簡單的工具,而不是另一個失控的全域變數。