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