Communitygithub.com

use-zustand

在 React 專案中使用 Zustand 管理客戶端狀態的準則,包含型別安全、效能優化與可維護性實務。

use-zustand 是什麼?

這項技能提供一套完整的 Zustand 狀態管理規範,專注於 React 客戶端狀態的處理,避免與 TanStack Query 管理的伺服器狀態重疊。它強調型別安全的 TypeScript 寫法,包括使用柯里化的 `create<State>()(...)` 形式、命名動作方法而非直接呼叫 `set()`、以及透過 selector 與 `useShallow` 精準選取狀態以避免不必要的重新渲染。技能深入探討了大規模 store 的組織方式,推薦使用 slices pattern 拆分領域邏輯並組合成單一 store,而非建立多個獨立的 store。同時也說明了週期性狀態可用 `persist` 中間件(含版號與遷移函式),開發階段善用 `devtools` 追蹤狀態變更流程,以及使用 `immer` 簡化巢狀狀態更新。最後,技能強調測試友善性,應匯出原始的 store 建立函式,以便在測試中建立隔離的 store 實例。這份指南適用於 Next.js 的 Client Component、無 Provider 架構,以及需要精細控制 re-render 的 React 應用。

相容平台~Claude Code~Codex CLI~Cursor
npx skills add C1131-G/skills

在你喜歡的 AI 中提問

開啟一個已預先載入此 Agent Skill 的新對話。

說明文件

use-zustand 是做什麼的?

請直接將此技能應用於 Zustand 狀態管理。並搭配 use-tanstack-query,以確保客戶端狀態與伺服器狀態各自歸屬正確的管理者。

同時,請將 enforce-code-quality 與 enforce-typescript-strict 應用於範圍內的檔案。

在 React 專案中撰寫或審查 Zustand store 時,一律套用以下規則。

1. Zustand 僅用於客戶端狀態 — 不要與 TanStack Query 重疊

Zustand 管理 UI/客戶端狀態:側邊欄開關、多步驟表單資料、主題、使用者正在編輯的購物車內容。伺服器資料(從 API 取得的任何資料)屬於 TanStack Query,不應重複複製到 Zustand store 中 — 請參閱 use-tanstack-query。將兩者責任混在一起(例如將取得的資料複製到 Zustand「以便存取」)會產生兩個可能不同步的事實來源。

2. 不需要 Provider,但僅限客戶端

Zustand store 是模組層級的單例 — 不需要 context wrapper,且可從任何地方透過匯入 hook 來存取。但這也代表它依賴瀏覽器 API 與每個客戶端的狀態,因此:

  • 僅在客戶端元件中初始化/使用 Zustand store(Next.js 中的 'use client')。
  • 絕不在伺服器元件中建立或讀取 Zustand store — 它不像新鮮的 QueryClient 那樣具有「每個請求」的隔離性,這麼做可能在使用者/請求之間洩漏狀態。

3. 使用 slices pattern 組織大型 store,而非單一扁平區塊

將不相關的關注點全部塞入單一 store(使用者、購物車、UI 主題、通知全部在同一個物件中)會難以導航和推理。應按領域拆分成 slice,然後組合成單一個 create() 呼叫:

// userSlice.ts
export interface UserSlice {
  user: User | null;
  setUser: (user: User) => void;
  logout: () => void;
}

export const createUserSlice: StateCreator<UserSlice> = (set) => ({
  user: null,
  setUser: (user) => set({ user }),
  logout: () => set({ user: null }),
});

// cartSlice.ts
export interface CartSlice {
  items: CartItem[];
  addItem: (item: CartItem) => void;
}

export const createCartSlice: StateCreator<CartSlice> = (set) => ({
  items: [],
  addItem: (item) => set((state) => ({ items: [...state.items, item] })),
});

// store.ts
type AppState = UserSlice & CartSlice;

export const useAppStore = create<AppState>()((...a) => ({
  ...createUserSlice(...a),
  ...createCartSlice(...a),
}));

僅在兩個領域真正獨立且永遠不需要互相互動時(例如認證 store vs. 純分析用 store),才考慮使用真正分離的 store(而非 slice)— 多個 store 會增加協調開銷,因此預設應先使用單一 store 中的 slice。

4. 使用具名動作方法,而非從元件直接呼叫原始 set()

應將動作公開為 store 上的具名函式;不要在元件中直接呼叫 store 的 set。

// 避免:可行,但不語意化 — 元件需要知道狀態形狀
const set = useAppStore((state) => state.set);
set({ theme: "dark" });

// 偏好:具名、自我說明的動作
const setTheme = useAppStore((state) => state.setTheme);
setTheme("dark");

在 slice 自己的動作中使用 get() 來讀取其他狀態或呼叫其他 slice 的動作(例如同時清除購物車的 logout 動作)— 但要注意 slice 之間互相呼叫時可能產生的循環依賴。

5. 永遠選取你需要的特定狀態區塊 — 絕不選取整個 store

// 避免:任何 store 變更都會觸發重新渲染,即使是不相關的欄位
const state = useAppStore();

// 偏好:僅在 `user` 變更時重新渲染
const user = useAppStore((state) => state.user);

6. 選取多個值或物件時使用 useShallow

從 store 中選取新構建的物件或陣列(例如 { user, theme })會在每次渲染時建立新的參考,即使底層值沒有改變,導致不必要的重新渲染。將其包裝在 useShallow 中,讓 Zustand 以淺層比較取代參考比較:

import { useShallow } from "zustand/react/shallow";

const { user, theme } = useAppStore(
  useShallow((state) => ({ user: state.user, theme: state.theme }))
);

7. 使用 immer 中介軟體處理巢狀狀態更新

手動展開多層巢狀狀態來更新一個欄位容易出錯且冗長。immer 中介軟體讓你使用直接的「變異」語法,而在底層仍以不可變方式套用:

import { immer } from "zustand/middleware/immer";

const useStore = create<State>()(
  immer((set) => ({
    nested: { deeply: { value: 0 } },
    increment: () =>
      set((state) => {
        state.nested.deeply.value += 1; // 看起來可變,實際上是不可變的
      }),
  }))
);

對於極高頻率的更新(例如 60fps 動畫狀態)可以跳過 immer,因為其開銷會有影響;對於典型的 CRUD 式狀態,這是值得付出少量成本的程式碼可讀性提升。

8. 使用柯里化的 create<State>()(...) 形式為 store 加上型別

對於非簡單的 store,普通的 create((set) => ({...})) 無法被 TypeScript 可靠地推斷,因為狀態泛型是不變的。務必使用柯里化形式:

interface CounterState {
  count: number;
  increment: () => void;
}

const useCounterStore = create<CounterState>()((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
}));

9. 不要儲存衍生/計算值 — 在 selector 或元件中計算

如果某個值可以從 store 中已有的其他狀態計算出來(從項目列表計算總數、從狀態欄位計算布林值),不要將其儲存為獨立欄位 — 一旦某個更新路徑忘記重新計算,它就會不同步。在 selector 中計算,或在元件中作為一般衍生值:

// 避免:需要手動同步的獨立 `itemCount` 欄位
addItem: (item) => set((state) => ({ items: [...state.items, item], itemCount: state.itemCount + 1 })),

// 偏好:在讀取時推導
const itemCount = useCartStore((state) => state.items.length);

10. 使用 persist 處理跨 session 應保留的狀態,devtools 僅在開發環境中使用

  • 使用 persist 中介軟體(由 localStorage/sessionStorage 或自訂儲存適配器支援)處理真正需要跨越頁面重新載入或新 session 的狀態(購物車內容、使用者偏好)— 不適用於每次造訪都應重設的狀態。
  • 在 persist 的選項中包含 version,如果形狀曾變更,則加入 migrate 函式,以免舊的持久化狀態在架構變更後導致應用程式無聲地出錯。
  • 使用 devtools 中介軟體與 Redux DevTools 整合,以便在開發期間檢查狀態變更,但確保在正式建置中不啟用。
export const useSettingsStore = create<SettingsState>()(
  devtools(
    persist(
      (set) => ({ theme: "light", setTheme: (theme) => set({ theme }) }),
      { name: "settings-storage", version: 1 }
    )
  )
);

11. 使用 devtools 追蹤狀態流程,並搭配 React DevTools 捕捉不必要的重新渲染

devtools 不只是可有可無的記錄器 — 積極用它來除錯狀態為何變更,而不只是知道它變更了:

  • 為每個動作命名,讓 devtools 時間軸顯示有意義的軌跡,而不是每個更新都是匿名的 set 條目。傳遞第三個參數給 set,作為動作名稱/型別:

    addItem: (item) =>
      set(
        (state) => ({ items: [...state.items, item] }),
        false,
        "cart/addItem" // 在 Redux DevTools 的動作列表中以此標籤顯示
      ),
    
  • 為每個 store 命名,透過 devtools(..., { name: "cart-store" }),當專案中有多個 store 時,避免 devtools 面板同時顯示來自所有 store 的模糊更新混合。

  • 使用 devtools 時間軸確認實際的狀態流程順序,當臭蟲看似競態條件或更新順序問題時 — 逐步檢視動作列表,確切了解何時、變更了什麼,以及前後狀態,而不是依賴散落在動作程式碼中的 console.log 來猜測。

  • 診斷不必要的重新渲染是另一種工具,並非 devtools 本身顯示的內容。 使用 React DevTools Profiler(或開發環境中的 why-did-you-render,如果你需要呼叫點層級的細節)來查看哪些元件因特定狀態變更而重新渲染及其原因。如果元件在 store 更新時重新渲染,但該更新並未觸及其選取的欄位,這表示你遺漏了 selector(規則 5)或 useShallow 包裝(規則 6)— 去修正 selector,而不是 store。

12. 讓 store 可測試:匯出原始的建立函式,而不只是綁定的 hook

同時匯出 hook(供元件使用)與底層的 StateCreator/store 建立函式(供測試使用),讓測試可以為每個測試建立全新、隔離的 store 實例,而不是在整個測試套件中共享模組層級的單例(這會導致測試之間洩漏狀態)。

套用這些規則

  • 新 store:如果涉及多個領域,從第一天起就使用 slices pattern 建立單一 store;使用柯里化的 create<State>()(...) 形式加上型別;只有當狀態實際需要時才加入 persist/devtools。
  • 審查現有程式碼:標記全 store 選取(未使用 selector 函式)、元件中直接呼叫原始 set() 而非具名動作、將衍生狀態儲存為獨立欄位、將伺服器取得的資料複製到 Zustand 而非 TanStack Query、任何從伺服器元件觸及的 store、使 devtools 時間軸難以閱讀的未命名動作/store,以及任何因不相關 store 變更而重新渲染的元件(遺漏 selector 或 useShallow)。

相關技能