TOOLS BOX/ガイド/Zustand / store 設計パターン
Concept

Zustand / store 設計パターン

Zustand を使うときの store 設計判断を整理するガイド。local state / global state / server data の責務を切り分け、store に置くものと置かないものの基準、action 設計、selector による再レンダリング制御、immutable update の原則を実務目線で整理する。

zustandstate-managementstoreselectoractionimmutableglobal-statelocal-statereact

どういう場面で使うか

  • ·複数のコンポーネントから同じ UI 状態を読み書きしたいとき(モーダルの開閉・サイドバー状態など)
  • ·props drilling を避けたい UI 状態がコンポーネントツリーを深く跨ぐとき
  • ·ユーザー操作で変化する状態で、サーバーへの永続化が不要なもの

注意点 / Pitfalls

  • ·form state を store に置くと、バリデーション・サブミット状態・フィールド値が store を汚す。form state は react-hook-form など form ライブラリに任せる
  • ·fetch したサーバーデータを store に常駐させると、キャッシュ無効化・再取得のロジックを自前で実装することになる。TanStack Query などのデータフェッチライブラリに任せる
  • ·selector なしで useStore() 全体を参照すると、store の任意フィールドが変わるたびに再レンダリングが発生する
  • ·derived state(合計・フィルタ結果など)を store に持つと、元データとの二重管理になりやすい。コンポーネント内で useMemo を使うか、Zustand の getState() で計算する
  • ·store のフィールドを直接 push や splice で変更すると参照が変わらず再レンダリングが起きない。スプレッドまたは Immer middleware を使う

補足

Zustand は「必要な範囲だけ使う」ライブラリ。useState で解決できるなら useState のほうがシンプル。グローバルに共有が必要な UI 状態に絞って store を設計すると、管理コストを最小に保てる。

3 種類の状態を切り分ける

React アプリの「状態」は、保存場所と責務によって 3 つに分けられます。

種類保存場所代表例向いているツール
Local stateコンポーネント内トグル・入力値・ローカルの表示フラグuseState / useReducer
Global stateアプリ全体で共有モーダル開閉・サイドバー状態・選択中タブZustand
Server dataサーバー + クライアントキャッシュAPI レスポンス・DB データTanStack Query / SWR

Zustand は Global state(複数コンポーネント間で共有が必要な UI 状態) に向きます。他の 2 つは別のツールで管理する方が設計がシンプルになります。

store に置くもの / 置かないもの

置くと良いもの

// ✅ モーダルの開閉(複数コンポーネントから制御する)
type ModalStore = {
  isOpen: boolean;
  modalType: "confirm" | "edit" | null;
  open: (type: "confirm" | "edit") => void;
  close: () => void;
};

// ✅ グローバルなサイドバー・ナビゲーション状態
type SidebarStore = {
  isCollapsed: boolean;
  toggle: () => void;
};

// ✅ ユーザーが選択した設定(言語・テーマ)でサーバー保存不要なもの
type PreferenceStore = {
  language: "ja" | "en";
  setLanguage: (lang: "ja" | "en") => void;
};

置かない方がよいもの

// ❌ form state — react-hook-form など form ライブラリに任せる
type FormStore = {
  name: string;        // フォームの入力値
  email: string;
  errors: Record<string, string>;
};
// → バリデーション・サブミット状態まで store で管理するとコストが増える

// ❌ サーバーから fetch したデータ — TanStack Query / SWR に任せる
type ProductStore = {
  products: Product[];       // API レスポンス
  isLoading: boolean;
  fetchProducts: () => void; // 再取得・キャッシュ無効化を自前実装することになる
};

// ❌ derived state — コンポーネント内の useMemo で計算する
type CartStore = {
  items: CartItem[];
  total: number;       // items から計算できる値を store に持つと二重管理になる
};

store の定義と action 設計

// store/modal.ts
import { create } from "zustand";

type ModalType = "confirm" | "edit" | "delete";

type ModalStore = {
  // State
  isOpen: boolean;
  modalType: ModalType | null;
  // Actions
  open: (type: ModalType) => void;
  close: () => void;
};

export const useModalStore = create<ModalStore>((set) => ({
  isOpen: false,
  modalType: null,

  open: (type) => set({ isOpen: true, modalType: type }),
  close: () => set({ isOpen: false, modalType: null }),
}));

action 設計の基本:

  • action は store の中に定義する(set を使う関数として)
  • コンポーネント側に set({ isOpen: false }) を直書きしない
  • action に意味のある名前を付けることで「何が起きたか」が読みやすくなる
// ✅ action 名で意図が伝わる
const { open } = useModalStore();
open("confirm");

// ❌ コンポーネント側で直接 set を呼ぶと散在する
useModalStore.setState({ isOpen: true, modalType: "confirm" });

selector と再レンダリング制御

useStore() をセレクタなしで使うと、store の任意のフィールドが変わるたびにそのコンポーネントが再レンダリングされます。

// ❌ セレクタなし: isOpen が変わっても modalType が変わっても再レンダリングされる
function ConfirmButton() {
  const store = useModalStore(); // store 全体を購読
  return <button onClick={() => store.open("confirm")}>確認</button>;
}

// ✅ 必要なものだけ取り出す: open 関数は参照が変わらないので再レンダリングされない
function ConfirmButton() {
  const open = useModalStore((s) => s.open);
  return <button onClick={() => open("confirm")}>確認</button>;
}
// ✅ 複数フィールドを取り出すときは useShallow で shallow 比較
import { useShallow } from "zustand/react/shallow";

function Modal() {
  const { isOpen, modalType } = useModalStore(
    useShallow((s) => ({ isOpen: s.isOpen, modalType: s.modalType }))
  );
  // isOpen か modalType が変わったときだけ再レンダリング
  ...
}

再レンダリング最小化の方針:

  • プリミティブ値(boolean・string・number)は直接 select → 参照比較で動く
  • オブジェクトを返すセレクタは useShallow を使う
  • action 関数は同じ参照が返るため、セレクタで取り出しても再レンダリングの原因にならない

immutable update の基本

Zustand は set() を呼んで新しいオブジェクトを渡すことで状態を更新します。直接ミューテーションしても再レンダリングが発生しません。

// ❌ 直接ミューテーション: 参照が変わらないため Zustand が変化を検知できない
addItem: (item) =>
  set((state) => {
    state.items.push(item); // ← state を直接書き換えている
    return state;           // 同じ参照を返しているので再レンダリングされない
  }),

// ✅ 新しい配列を返す(スプレッド)
addItem: (item) =>
  set((state) => ({ items: [...state.items, item] })),

// ✅ または Immer middleware を使う(ネストが深い場合)
// → zustand-with-immer サンプルを参照
addItem: (item) =>
  set(
    produce((state) => {
      state.items.push(item); // Immer がコピーを作るので安全
    })
  ),

ネストが浅いうちはスプレッドで十分です。ネストが 2〜3 階層を超えたら Immer の導入を検討します。

derived state の扱い

store に計算済みの値を持つと、元データと二重管理になります。

// ❌ total を store に持つ: items が変わるたびに total も更新が必要
type CartStore = {
  items: CartItem[];
  total: number;
  addItem: (item: CartItem) => void;
};

// ✅ コンポーネント内で useMemo で計算する
function CartSummary() {
  const items = useCartStore((s) => s.items);
  const total = useMemo(
    () => items.reduce((sum, item) => sum + item.price * item.quantity, 0),
    [items]
  );
  return <p>合計: {total} 円</p>;
}

// ✅ または getState() で計算する(コンポーネント外で使う場合)
const total = useCartStore
  .getState()
  .items.reduce((sum, item) => sum + item.price * item.quantity, 0);

よくある誤用

store に何でも入れる

❌ store に入れすぎた場合:
  ・フォームの入力値
  ・API レスポンスのキャッシュ
  ・ページごとのローディング状態
  ・算出値(合計・フィルタ結果)

→ store が大きくなるほど更新漏れ・副作用の管理が複雑になる
→「useState で解決できないか?」を先に考える

セレクタなしで大きな store を購読する

// ❌ store 全体を購読: 無関係なフィールドが変わっても再レンダリングされる
const store = useAppStore();

// ✅ 使うフィールドだけ取り出す
const userName = useAppStore((s) => s.user.name);

form state を store で管理する

// ❌ フォームの入力値を store に入れるパターン
const { name, setName, email, setEmail } = useFormStore();

// ✅ react-hook-form など form ライブラリを使う
const { register, handleSubmit } = useForm();
// → バリデーション・エラー・isDirty・isSubmitting まで一貫して管理できる

fetch データを store で管理する

// ❌ API データを store でキャッシュ管理
const { products, fetchProducts, isLoading } = useProductStore();
useEffect(() => { fetchProducts(); }, []);
// → 再取得タイミング・エラー・stale 状態を全部自前実装することになる

// ✅ TanStack Query など fetch ライブラリに任せる
const { data: products, isLoading } = useQuery({
  queryKey: ["products"],
  queryFn: fetchProducts,
});

セレクタの詳細と useShallow → zustand-selector-optimization

Immer による immutable update → zustand-with-immer

モーダル状態管理の実装例 → nextjs-15-zustand-modal-state

関連ドキュメント

関連サンプル

同じテーマや技術スタックを使った実装例