web-state-zustand について
Zustand を使うフロントエンド開発者向けの状態管理パターン集です。状態の置き場所の選び方に加え、セレクター、購読、ミドルウェア、v5 の動作を扱います。
主な機能
- 01状態の適切な置き場所の選択
- 02アトミックセレクターと浅い比較
- 03devtools と persist の利用
- 04用途別ストアへの分割
- 05共有可能な状態の URL 保存
活用シーン
- 01複数コンポーネントでの UI 状態共有
- 02状態変更による再描画の抑制
- 03ユーザー設定の永続化
- 04フィルターやページ番号の URL 保存
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ agents-inc/ skills --skill web-state-zustandインストール後、Claude Code で/web-state-zustandを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
metadata.yamlreference.mdexamples/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>