Communitygithub.com

use-zustand

Zustand 状態管理スキル。クライアント専用状態を扱い、TanStack Query と組み合わせてサーバー状態と分離します。

use-zustand とは?

このスキルは、React プロジェクトにおいて Zustand ストアを適切に設計・実装するためのベストプラクティスを提供します。Zustand はクライアント専用の状態管理ライブラリであり、TanStack Query と明確に役割分担することで、UI/クライアント状態(サイドバーの開閉、フォームデータ、テーマなど)とサーバー状態(API取得データ)を混在させず、二重管理による同期ずれを防止します。Provider不要でモジュールレベルのシングルトンとして機能するため、Server Components では利用できません。スライスパターンによるドメイン分割、名前付きアクション、useShallow による部分選択、immer ミドルウェア、persist/devtools の適切な使用、型安全なカリー形式の create、派生値の非保存、テスト容易性のための生クリエーターのエクスポートなど、実践的なルールを網羅しています。既存コードのレビュー時にも適用可能なチェックポイントが含まれており、Zustand の効率的かつ保守性の高い使い方を徹底します。

対応~Claude Code~Codex CLI~Cursor
npx skills add C1131-G/skills

お気に入りのAIに質問する

このエージェントスキルを事前に読み込んだ状態で新しいチャットを開きます。

ドキュメント

use-zustand は何をしますか?

このスキルを直接 Zustand の状態管理に使用します。use-tanstack-query と組み合わせて、クライアント状態とサーバー状態をそれぞれ適切な所有者に分離してください。

また、対象範囲のファイルには enforce-code-quality と enforce-typescript-strict も適用してください。

React プロジェクトで Zustand ストアを記述またはレビューする際は、以下のルールを常に適用してください。

1. Zustand はクライアント専用状態 — TanStack Query と重複させない

Zustand は UI/クライアント状態(サイドバーの開閉、マルチステップフォームデータ、テーマ、ユーザーが編集中のカート内容)を担当します。サーバーデータ(API から取得したもの)は TanStack Query に属し、Zustand ストアに複製してはいけません — use-tanstack-query を参照してください。2 つの責務を混在させる(例:取得したデータを「アクセスしやすくするため」に Zustand にコピーする)と、2 つの真実の情報源が作成され、同期がずれる可能性があります。

2. Provider は不要、ただしクライアント専用

Zustand ストアはモジュールレベルのシングルトンです。コンテキストラッパーは不要で、フックをインポートすることでどこからでもアクセスできます。ただし、ブラウザ API とクライアントごとの状態に依存するため、次の点に注意してください:

  • Zustand ストアの初期化・使用は Client Components のみで行ってください(Next.js では 'use client')。
  • Server Component で Zustand ストアを作成したり読み取ったりしないでください。新しい QueryClient の場合のような「リクエストごとの分離」がないため、ユーザー間やリクエスト間で状態が漏洩するリスクがあります。

3. 大規模ストアはスライスパターンで整理、フラットな一塊にしない

1 つのストアに無関係な関心事(ユーザー、カート、UI テーマ、通知など全てを 1 つのオブジェクトに詰め込む)を詰め込むと、ナビゲートや推論が困難になります。ドメインごとにスライスに分割し、それらを 1 つの 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),
}));

本当に独立していて相互に作用する必要がない 2 つのドメイン(例:認証ストアと分析専用ストア)の場合のみ、別々のストア(スライスではなく)を検討してください。複数のストアは調整のオーバーヘッドを増やすため、デフォルトでは 1 つのストア内のスライスを優先してください。

4. 名前付きアクションメソッドを使用し、コンポーネントから生の set() を呼ばない

アクションはストアの名前付き関数として公開し、コンポーネントからストアの set を直接呼び出さないでください。

// 避ける:動作はするが意味的でない — コンポーネントが状態の形状を知る必要がある
const set = useAppStore((state) => state.set);
set({ theme: "dark" });

// 推奨:名前付きで自己文書化されたアクション
const setTheme = useAppStore((state) => state.setTheme);
setTheme("dark");

スライス内のアクション内で他の状態を読み取ったり、他のスライスのアクションを呼び出したりするには get() を使用します(例:カートもクリアする logout アクション)。ただし、スライス間で相互に呼び出し合う循環依存には注意してください。

5. 必要な状態の特定のスライスのみを常に選択 — ストア全体を選択しない

// 避ける:無関係なフィールドでもストアが変更されるたびに再レンダリングされる
const state = useAppStore();

// 推奨:`user` が変更されたときのみ再レンダリングされる
const user = useAppStore((state) => state.user);

6. 複数の値またはオブジェクトを選択するときは useShallow を使用する

ストアから新しく構築されたオブジェクトや配列(例:{ user, theme })を選択すると、基礎となる値が変更されていなくても毎レンダリングで新しい参照が作成され、不必要な再レンダリングが発生します。useShallow でラップすることで、Zustand が選択された値を参照比較ではなく浅く比較するようにします:

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

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

7. ネストされた状態更新には immer ミドルウェアを使用する

手動で複数レベルのネストされた状態を展開して 1 つのフィールドを更新するのは、エラーが発生しやすく冗長です。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>()(...) で型付けする

プレーンな create((set) => ({...})) は、単純なストア以外では TypeScript で確実に推論できません。状態ジェネリックが不変であるためです。常にカリー形式を使用してください:

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

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

9. 派生/計算値を保存しない — セレクターまたはコンポーネントで計算する

値がストア内の他の状態から計算できる場合(アイテムリストからの合計、ステータスフィールドからのブール値)、それを独自のフィールドとして保存しないでください。ある更新パスが再計算を忘れた瞬間に同期がずれます。セレクターで計算するか、コンポーネント内でプレーンな派生値として計算してください:

// 避ける:手動で同期を保つ必要がある別個の `itemCount` フィールド
addItem: (item) => set((state) => ({ items: [...state.items, item], itemCount: state.itemCount + 1 })),

// 推奨:読み取り時に派生させる
const itemCount = useCartStore((state) => state.items.length);

10. セッションをまたいで存続させる状態には persist を、開発時のみ devtools を使用する

  • persist ミドルウェア(localStorage/sessionStorage、またはカスタムストレージアダプターでバックアップ)は、ページのリロードや新しいセッションをまたいで本当に存続させる必要がある状態(カート内容、ユーザー設定)にのみ使用してください。訪問のたびにリセットすべき状態には使用しないでください。
  • 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 の第 3 引数にアクション名/タイプを渡します:

    addItem: (item) =>
      set(
        (state) => ({ items: [...state.items, item] }),
        false,
        "cart/addItem" // Redux DevTools のアクションリストにこのラベルで表示される
      ),
    
  • プロジェクトに複数のストアがある場合は、devtools(..., { name: "cart-store" }) で各ストアに名前を付ける ことで、devtools パネルにすべてのストアからの更新が曖昧に混在して表示されるのを防ぎます。

  • バグが競合状態や更新順序の問題のように見える場合、devtools タイムラインを使用して実際の状態フローの順序を確認します。アクションリストをステップ実行して、何が、いつ、どのような前後の状態で変更されたかを正確に確認します。アクションコードに散在する console.log から推測するのではなく。

  • 不必要な再レンダリングの診断は別のツールであり、devtools 自体では表示されません。 React DevTools Profiler(または開発時に呼び出しサイトレベルの詳細が必要な場合は why-did-you-render)を使用して、特定の状態変更時にどのコンポーネントが再レンダリングされたか、その理由を確認します。コンポーネントが、選択していないフィールドに触れないストア更新で再レンダリングされる場合、それはセレクター(ルール 5)または useShallow ラップ(ルール 6)が不足しているシグナルです。ストアではなくセレクターを修正してください。

12. ストアをテスト可能にする:バインドされたフックだけでなく、生のクリエーターもエクスポートする

フック(コンポーネント用)と基になる StateCreator/ストア作成関数(テスト用)の両方をエクスポートすることで、テストはテストスイート全体でモジュールレベルのシングルトンを共有する代わりに、テストごとに新しく独立したストアインスタンスを作成できます(これにより、テスト間で状態が漏洩するのを防ぎます)。

これらのルールの適用

  • 新しいストア:複数のドメインが関係する場合は、初日からスライスパターンを使用した 1 つのストアから始めます。カリー形式の create<State>()(...) で型付けします。persist/devtools は、状態が実際にそれらを必要とする場合のみ追加します。
  • 既存コードのレビュー:セレクター関数なしのストア全体選択、コンポーネントからの生の set() 呼び出し(名前付きアクションの代わり)、独自のフィールドとして保存された派生状態、TanStack Query の代わりに Zustand に複製されたサーバー取得データ、Server Component から触れられたストア、devtools タイムラインを読みづらくする名前のないアクション/ストア、および無関係なストア変更で再レンダリングされるコンポーネント(セレクターまたは useShallow の欠如)をフラグします。

関連スキル