mcpField
W
web-state-zustand

web-state-zustand

github.com/agents-inc開発2689 回インストール2 日前に更新
リポジトリ

web-state-zustand について

Zustand を使うフロントエンド開発者向けの状態管理パターン集です。状態の置き場所の選び方に加え、セレクター、購読、ミドルウェア、v5 の動作を扱います。

主な機能

  1. 01状態の適切な置き場所の選択
  2. 02アトミックセレクターと浅い比較
  3. 03devtools と persist の利用
  4. 04用途別ストアへの分割
  5. 05共有可能な状態の URL 保存

活用シーン

  1. 01複数コンポーネントでの UI 状態共有
  2. 02状態変更による再描画の抑制
  3. 03ユーザー設定の永続化
  4. 04フィルターやページ番号の URL 保存

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/agents-inc/skills --skill web-state-zustand

インストール後、Claude Code で/web-state-zustandを入力して読み込まれたか確認します。

同梱ファイル

SKILL.md と一緒にインストールされる参考資料やスクリプト。

metadata.yaml
reference.md
examples/
core.md

詳細

Zustandのパターン

クイックガイド: 2つ以上のコンポーネントが読み取る状態はZustandで管理し、1つのコンポーネントだけが読み取る状態はuseStateで管理します。フィルターや検索の状態はURLで管理し、シングルトンの注入にはContextを使い、状態は持たせません。Zustand v5では3つの点が変わります。zustand/react/shallowのuseShallowが従来の等価関数を渡す第2引数に置き換わり、セレクターは安定した参照を返さないと無限ループし、persistは作成時の初期状態を取得しなくなりました。

詳細なリソース:

  • examples/core.md — ストアの設定、アトミックセレクター、useShallow、Contextのアンチパターン、URLの状態
  • reference.md — ミドルウェアの参照、v5への移行に関する注意事項、ピア依存関係の要件

<critical_requirements>

Zustandのコードを書く前に

2つ目のコンポーネントが値を読み取るようになった時点で、その値をストアに移してください。 値をコンポーネント間で受け渡すと、その経路上にあるすべてのコンポーネントが結合され、後から移動する際にはその連鎖をほどく必要があります。

useStoreを呼び出すたびに、値を1つだけ選択してください。 そうすれば、その値が変わったときにのみコンポーネントが再レンダリングされます。セレクターを指定せずに呼び出すと、ストア全体を購読します。

すべてのセレクターから安定した参照を返してください。 セレクター内で作成したオブジェクトや関数は、呼び出すたびに新しい参照になります。v5では、これにより再レンダリングが止まらなくなります。

partializeを通じて、設定だけを永続化してください。 ストレージから復元された一時的なUI(再び開いてしまうモーダルや、閉じた状態を記憶するサイドバーなど)は、バグのように感じられます。

サーバーデータをストアに入れないでください。 キャッシュ、無効化、再取得にはそれらに適したレイヤーが必要です。ストアに入れると、気づかないうちに古くなるコピーができてしまいます。

</critical_requirements>


自動検出: create from zustand、zustand/middleware、zustand/react/shallow、useShallow、createWithEqualityFn、zustand/traditional、persist、partialize、devtools、ストアスライス

対象範囲:

  • 値に応じてストア、useState、URL、Contextのどれを使うか選択する
  • devtoolsとpersistを使ってストアを設定する
  • セレクターの形と再レンダリングの動作
  • 状態を目的別のストアに分割する

他の場所で扱う内容:

  • サーバーデータ — キャッシュ、無効化、再取得はネットワークを管理するレイヤーの役割です。ストアはそれに該当しません
  • ルーティングの仕組み — このスキルでは、どの状態をURLに置くかを説明しますが、各ルーターでの読み書き方法は扱いません
  • フォームフィールドの状態 — フォームが開いている間、各フィールドの値はフォームライブラリが管理します

Zustandは、フックを備えたサブスクリプションのプリミティブです。そのパフォーマンスはすべてセレクターにかかっています。ストアは変更のたびにすべての購読者に通知し、その通知がレンダリングにつながるかどうかをセレクターが判断します。つまり、ストアの設計とはセレクターの設計です。

  • 大きなストア1つより、小さなストアを複数使う — ストアはコードの単位であると同時に、購読の単位でもあります
  • ビジネスロジックはアクションに置く — コンポーネントはtoggleSidebar()を呼び出し、切り替えの意味はストアが決定します
  • エクスポートするのはフックであり、ストア作成関数ではない — 生のストアは変更可能なグローバルであり、エクスポートするとどこからでも書き込みが行われるおそれがあります
  • まずはアトミックセレクターを使う — 値が1つだけなら、比較関数は不要です

<decision_framework>

値の置き場所

値置き場所理由
2つ以上のコンポーネントから読み取られるストア必要な再レンダーだけが行われ、後で解きほぐす props の連鎖もありません
1つのコンポーネントから読み取られるuseStateほかに監視するものがないため、ほかに必要とするものもありません
フィルター、クエリ、ページ、並べ替えURL共有やブックマークができ、戻るボタンも機能します
起動時に設定されるシングルトンContext変更されないため、再レンダーのコストが発生しません
API から取得される値このスキルの対象外キャッシュと無効化が必要ですが、ストアにはその機能がありません

Context は、誤解されやすい項目です。Context は変更されない値を受け渡すためのものであり、変更される状態に使うと、変更のたびにすべてのコンシューマーが再レンダーされ、対象外にする方法もありません。

</decision_framework>


基本パターン

パターン 1:ストアのセットアップ

devtools は検査用、persist はタブを閉じた後も保持するフィールド用、partialize はそれ以外をストレージに保存しないために使います。

export const useUIStore = create<UIState>()(
  devtools(
    persist(
      (set) => ({
        theme: DEFAULT_THEME,
        sidebarOpen: true,
        toggleSidebar: () =>
          set((state) => ({ sidebarOpen: !state.sidebarOpen })),
      }),
      { name: UI_STORAGE_KEY, partialize: (state) => ({ theme: state.theme }) },
    ),
  ),
);

コード全文:examples/core.md

パターン 2:アトミックセレクター

呼び出しごとに1つの値を取得するのがデフォルトであり、ストアが高速である理由です。

const toggleSidebar = useUIStore((state) => state.toggleSidebar);
const isOpen = useUIStore((state) => state.sidebarOpen);

コード全文:examples/core.md

パターン 3:複数の値に対する useShallow

1つのストアから3つ以上の値を取得する場合は、3つのサブスクリプションよりも、useShallow を1回呼び出すほうが効率的です。

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

const { sidebarOpen, theme } = useUIStore(
  useShallow((state) => ({
    sidebarOpen: state.sidebarOpen,
    theme: state.theme,
  })),
);

コード全文:examples/core.md

パターン 4:状態ではなく、値の注入に Context を使う

Context は変更されない値、つまりクライアント、接続、起動時に読み込まれる設定を受け渡します。

const DatabaseContext = createContext<Database | null>(null);

コード全文:examples/core.md

パターン 5:共有可能なフィルターには URL の状態を使う

フィルター、検索、ページネーション、並べ替えは URL に置きます。そうすることで、再読み込み後も、同僚とのチャットに貼り付けた後も状態が保たれます。

const searchParams = new URLSearchParams(window.location.search);
const category = searchParams.get("category") ?? undefined;

コード全文:examples/core.md


<red_flags>

注意すべき点

実行時に問題が発生します:

  • インラインオブジェクトまたは関数を返すセレクター — 呼び出すたびに新しい参照が作られるため、v5 では再レンダーが止まりません。フォールバックをモジュール定数として定義してください。
  • 2つ目の等価関数引数を指定して create() を呼び出す — v5 では削除されているため、その引数は無視され、比較が行われないままになります。useShallow、または zustand/traditional の createWithEqualityFn を使ってください。
  • 永続化されたストアから、計算済みの初期値を作成直後に読み取る — v5 の persist はその値を保存しませんでした。作成後に setState で設定してください。

予想外の動作:

  • セレクターを指定せずに useStore() を使うと、ストア全体がサブスクライブされるため、コンポーネントが読み取らないフィールドの変更でも再レンダーされます。
  • 2つのアトミックセレクターが同じ値を返しても、どちらかが変更されると別々にレンダーされます。useShallow が比較するのはオブジェクトであり、個々の読み取り値ではありません。
  • プロバイダーの値オブジェクトが再構築されるたびに、Context はすべてのコンシューマーを再レンダーします。値をメモ化しない限り、これは毎回のレンダーで発生します。また、メモ化していても、1つのフィールドが変わると、すべてのフィールドを読み取るコンポーネントが再レンダーされます。
  • 検索パラメーターは文字列です。?page=2 は "2" として読み取られ、?open=false は解析するまで truthy です。
  • 大きなストアを1つ使うと、サブスクリプション対象も大きくなります。そのストアを読み取るすべてのコンポーネントが、そのストアへのすべての書き込みと競合します。

</red_flags>

コメント