判断の起点:「複数コンポーネントから読み書きが必要か」
この状態は複数のコンポーネントから読み書きされるか?
No → private state(useState / useReducer)で解決する
Yes → コンポーネントが破棄されても維持が必要か?
No → state lifting か context を検討する
Yes → global store(Zustand など)を使う
「グローバルに置けば便利そう」という理由だけでは store に入れません。共有の必要性が明確にあるかが判断の起点です。
global store に向くデータ
// ✅ モーダルの開閉状態(複数のボタン・ページから制御される)
type ModalStore = {
isOpen: boolean;
modalType: "confirm" | "edit" | null;
open: (type: "confirm" | "edit") => void;
close: () => void;
};
// ✅ ユーザーが選択した設定(ページ跨ぎで維持が必要)
type PreferenceStore = {
sidebarCollapsed: boolean;
toggleSidebar: () => void;
};
// ✅ ショッピングカート(ページ間で維持・複数コンポーネントから操作)
type CartStore = {
items: CartItem[];
addItem: (item: CartItem) => void;
removeItem: (id: string) => void;
};
global store に向く状態の特徴:
- 複数のコンポーネントから読み書きされる
- コンポーネントが破棄(アンマウント)されても維持したい
- props drilling や state lifting では届かない深さのツリーにある
private state に閉じるべきデータ
表示制御の一時状態(hover・focus・open/close)
// ✅ ドロップダウン・ツールチップの開閉 → そのコンポーネント内で完結する
function Dropdown() {
const [isOpen, setIsOpen] = useState(false); // private state で十分
return (
<div>
<button onClick={() => setIsOpen((o) => !o)}>開く</button>
{isOpen && <ul>...</ul>}
</div>
);
}
// ❌ store に入れると global が汚れる上、他のコンポーネントと競合しやすい
const useDropdownStore = create(() => ({ isOpen: false }));
判断基準: そのコンポーネントが画面から消えたら状態も消えてよいか? → Yes なら private state
form state
// ✅ react-hook-form に任せる
function LoginForm() {
const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm();
// 入力値・エラー・isDirty・isSubmitting はすべて form ライブラリ内に閉じる
}
// ❌ store に入れると送信後のリセット・バリデーション連携が複雑になる
type FormStore = {
email: string;
password: string;
errors: Record<string, string>;
setField: (key: string, value: string) => void;
};
form state の問題点は「送信後のリセット」「バリデーション状態」「isDirty / isTouched」まで store で管理する必要が生まれることです。react-hook-form はこれらを一括して扱う専用ツールです。
hover / focus などのインタラクション状態
// ✅ hover は useRef / CSS :hover / useState で完結させる
function Card() {
const [hovered, setHovered] = useState(false);
return (
<div
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
className={hovered ? "shadow-lg" : ""}
>
...
</div>
);
}
// ❌ hover 状態を global store に置く理由はほぼない
server data を store に常駐させるべきでない場面
❌ store で API キャッシュを管理しようとすると:
・再取得タイミングの管理(stale になったら?)
・loading / error 状態の管理
・複数画面でのキャッシュ共有
・リクエスト重複の制御
これらをすべて自前実装することになる
✅ TanStack Query / SWR に任せると:
・キャッシュ・再取得・loading / error を自動管理
・staleTime / cacheTime で鮮度を制御できる
・楽観更新も onMutate で実装できる
// ❌ API データを store に常駐させるパターン
const useProductStore = create((set) => ({
products: [] as Product[],
isLoading: false,
fetchProducts: async () => {
set({ isLoading: true });
const data = await api.getProducts();
set({ products: data, isLoading: false });
},
}));
// ✅ TanStack Query に任せる
function ProductList() {
const { data: products, isLoading } = useQuery({
queryKey: ["products"],
queryFn: api.getProducts,
staleTime: 60_000,
});
}
例外: サーバーから取得した後にユーザーが編集・並び替えなどの操作を行い、その結果をローカルで保持するケースは store が適することがあります。「取得したデータをそのまま表示する」なら fetch ライブラリに任せ、「取得後に加工してローカルで管理する」なら store の検討対象になります。
derived state を store に持つべきでない
// ❌ total を store に持つ → items と二重管理になる
type CartStore = {
items: CartItem[];
total: number; // items から計算できる
addItem: (item: CartItem) => void;
// addItem のたびに total も更新する必要がある → 更新忘れのリスク
};
// ✅ コンポーネント内で 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.toLocaleString()} 円</p>;
}
derived state を store に持つと、元データを更新するたびに derived state も更新する必要があり、更新漏れがバグの原因になります。
selector なしの広域購読で起きること
// ❌ store 全体を購読 → 無関係なフィールドが変わっても再レンダリングされる
function Header() {
const store = useAppStore(); // isOpen / theme / user / cartItems...すべてを購読
// cartItems が変わるだけで Header が再レンダリングされる
return <nav>{store.user?.name}</nav>;
}
// ✅ 使うフィールドだけ select する
function Header() {
const userName = useAppStore((s) => s.user?.name);
// user.name が変わったときだけ再レンダリング
return <nav>{userName}</nav>;
}
判断チェックリスト
置こうとしている状態を確認する:
□ 複数のコンポーネントから読み書きされるか?
No → useState / useReducer で private state にする
□ コンポーネントが消えても維持する必要があるか?
No → state lifting か context を先に検討する
□ API から取得したデータをそのまま格納したいだけか?
Yes → TanStack Query / SWR に任せる
□ 他のフィールドから計算できる値か(合計・フィルタ結果)?
Yes → useMemo で計算する(store には入れない)
□ そのコンポーネント専用の表示状態(hover・dropdown の開閉)か?
Yes → private state に閉じる
□ フォームの入力値・バリデーション・送信状態か?
Yes → react-hook-form などの form ライブラリに任せる
すべての No を通過したものだけが global store の候補
よくある誤用まとめ
| 状態の種類 | 誤った配置 | 正しい配置 |
|---|---|---|
| hover / focus / dropdown 開閉 | global store | useState(private) |
| フォームの入力値・エラー | global store | react-hook-form |
| API レスポンス(表示用) | global store | TanStack Query / SWR |
| 合計・フィルタ済みリスト | store の derived state | useMemo |
| モーダルの開閉(複数箇所から操作) | private state | global store ✅ |
| テーマ・ロケール(ツリー全体) | global store(更新頻度次第) | context でも可 |
store 設計の全体像 → Zustand / store 設計パターン
データ受け渡し手段の選択 → React Hook によるデータ受け渡しパターン
selector と再レンダリング制御の実装例 → zustand-selector-optimization