状態管理概論
状態管理はWebアプリの複雑さの根源。ローカル状態、グローバル状態、サーバー状態、URL状態の分類を理解し、各カテゴリに最適なツールを選択することで、シンプルで保守しやすい状態管理を実現する。
状態管理概論
状態管理はWebアプリの複雑さの根源。ローカル状態、グローバル状態、サーバー状態、URL状態の分類を理解し、各カテゴリに最適なツールを選択することで、シンプルで保守しやすい状態管理を実現する。
前提知識
この章を効果的に学習するために、以下の知識を事前に習得しておくことを推奨する:
- REST API の概念、fetch/axios の基本的な使い方、非同期処理の理解
- Reactの基本フック
useState: コンポーネント内でのローカル状態管理useReducer: より複雑な状態遷移の管理useEffect: 副作用の扱い方とクリーンアップ
- 単方向データフロー
- React の宣言的UI(
UI = f(state))の考え方 - Props の受け渡しと状態の持ち上げ(State Lifting)
- イベントハンドラによる状態更新フロー
- React の宣言的UI(
この章で学ぶこと
- 状態の4つのカテゴリを理解する
- 各カテゴリに適したツールの選定基準を把握する
- 状態管理の設計原則を学ぶ
- パフォーマンスを考慮した状態設計ができるようになる
- 実務での状態管理アンチパターンを回避できるようになる
- 大規模アプリケーションでの状態管理戦略を策定できるようになる
1. 状態とは何か
Webアプリケーションにおける「状態」とは、アプリケーションが現在どのような振る舞いをすべきかを決定するデータの総体である。ボタンが押されたか、ユーザーがログインしているか、APIからどんなデータが返ってきたか、URLにどんなパラメータが含まれているか。これらすべてが「状態」であり、UIはこの状態の関数として描画される。
UI = f(state)
この式が意味すること:
- 同じ状態が与えられれば、同じUIが描画される
- 状態が変化するとUIが再描画される
- UIの問題 = 状態の問題(デバッグの基本方針)
Reactの基本的なメンタルモデル:
1. 状態を宣言する(useState, useReducer)
2. 状態に基づいてUIを宣言的に記述する
3. イベントハンドラで状態を更新する
4. Reactが差分検出して効率的にDOMを更新する
重要な区別:
- 状態(State): 時間とともに変化するデータ
- 定数(Constant): 変化しないデータ → 状態にすべきでない
- 導出値(Derived): 既存の状態から計算可能 → 状態にすべきでない
- Props: 親から渡されるデータ → 子コンポーネントの状態にすべきでない
1.1 状態管理が難しい理由
なぜ状態管理が複雑化するのか:
① 状態の散在:
→ 同じデータが複数のコンポーネントで必要
→ どこに配置すべきかの判断が難しい
→ Props Drilling vs Context vs 外部ストア
② 状態の同期:
→ クライアント側のキャッシュとサーバーのデータのズレ
→ 複数タブ間での状態同期
→ オフライン/オンライン切り替え時の整合性
③ 状態の正規化:
→ ネストしたオブジェクトの更新の複雑さ
→ 同じエンティティが複数の場所に存在
→ 部分的な更新と全体の整合性
④ 非同期状態:
→ ローディング、エラー、成功の3状態の管理
→ 競合する複数のリクエストの処理
→ 楽観的更新とロールバック
⑤ パフォーマンス:
→ 不必要な再レンダリングの発生
→ メモリリーク(適切なクリーンアップの欠如)
→ 巨大な状態ツリーの管理コスト
2. 状態の4つのカテゴリ
4つの状態カテゴリ:
① ローカル状態(UI State):
→ コンポーネント固有の一時的な状態
→ モーダルの開閉、フォーム入力値、ホバー状態
→ ツール: useState, useReducer
→ ライフサイクル: コンポーネントのマウント〜アンマウント
② グローバル状態(Client State):
→ 複数コンポーネントで共有する状態
→ テーマ、言語設定、ユーザー認証状態
→ ツール: Zustand, Jotai, Context
→ ライフサイクル: アプリ全体のライフタイム
③ サーバー状態(Server State):
→ APIから取得したデータ
→ ユーザー一覧、商品データ、注文履歴
→ ツール: TanStack Query, SWR
→ ライフサイクル: キャッシュの有効期限に基づく
④ URL状態(URL State):
→ URLに反映される状態
→ 検索クエリ、フィルタ、ページ番号、ソート
→ ツール: useSearchParams, nuqs
→ ライフサイクル: ナビゲーションに連動
よくある間違い:
✗ サーバー状態を useState で管理
→ キャッシュ、リトライ、再検証が全て手動に
→ TanStack Query に任せるべき
✗ ローカル状態をグローバルに置く
→ 不要な再レンダリング
→ useState で十分
✗ URL状態を useState で管理
→ ブックマーク不可、共有不可
→ useSearchParams に
✗ 導出値を状態として管理
→ 同期が崩れるバグの温床
→ useMemo で計算すべき
原則:
「最も局所的な場所で、最も適切なツールで管理する」
2.1 状態カテゴリの判定フローチャート
状態カテゴリ判定フロー:
Q1: そのデータはAPIから取得するものか?
│
├─ Yes → サーバー状態(TanStack Query / SWR)
│
└─ No
│
Q2: URLに反映すべきか?(ブックマーク/共有で保持したい?)
│
├─ Yes → URL状態(useSearchParams / nuqs)
│
└─ No
│
Q3: 複数のコンポーネントで共有するか?
│
├─ Yes
│ │
│ Q4: 更新頻度はどの程度か?
│ │
│ ├─ 低頻度(テーマ/認証/言語)→ Context
│ │
│ └─ 中〜高頻度 → Zustand / Jotai
│
└─ No → ローカル状態(useState / useReducer)
実務での判断例:
「ショッピングカートの中身」
→ 複数ページで参照 → グローバル状態
→ ただしサーバーに永続化するなら → サーバー状態
「検索結果のフィルタ条件」
→ URLに反映してブックマーク可能にしたい → URL状態
「フォームの入力途中のデータ」
→ そのページでしか使わない → ローカル状態
「ログイン中ユーザーの情報」
→ APIから取得 → サーバー状態(TanStack Queryで管理)
→ 認証トークン自体 → グローバル状態
3. ローカル状態の詳細
3.1 useState: 最もシンプルな状態管理
// useState: 最もシンプル
function ToggleButton() {
const [isOpen, setIsOpen] = useState(false);
return (
<button onClick={() => setIsOpen(!isOpen)}>
{isOpen ? 'Close' : 'Open'}
</button>
);
}
// フォーム入力の管理
function LoginForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
try {
await login(email, password);
} catch (err) {
setError('ログインに失敗しました');
}
};
return (
<form onSubmit={handleSubmit}>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="メールアドレス"
/>
<div className="password-field">
<input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="パスワード"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? '隠す' : '表示'}
</button>
</div>
{error && <p className="error">{error}</p>}
<button type="submit">ログイン</button>
</form>
);
}3.2 useState の注意点
// ① バッチ更新の理解
function Counter() {
const [count, setCount] = useState(0);
// NG: 同じレンダリングサイクル内の値を参照
const handleClick = () => {
setCount(count + 1);
setCount(count + 1); // count は古い値のまま → 結果: +1
};
// OK: 関数型アップデートで前の値を参照
const handleClickCorrect = () => {
setCount((prev) => prev + 1);
setCount((prev) => prev + 1); // prev は更新後の値 → 結果: +2
};
return <button onClick={handleClickCorrect}>{count}</button>;
}
// ② 初期値の遅延初期化
function ExpensiveComponent() {
// NG: 毎レンダリングで computeExpensiveValue() が実行される
// (結果は初回のみ使われるが、関数呼び出し自体は毎回行われる)
const [value, setValue] = useState(computeExpensiveValue());
// OK: 関数を渡すと初回のみ実行される
const [value2, setValue2] = useState(() => computeExpensiveValue());
return <div>{value2}</div>;
}
// ③ オブジェクト状態の更新
function UserProfile() {
const [user, setUser] = useState({
name: 'Taro',
email: 'taro@example.com',
preferences: {
theme: 'dark',
language: 'ja',
},
});
// NG: 直接変更(Reactが変更を検知できない)
const updateThemeBad = () => {
user.preferences.theme = 'light';
setUser(user); // 同じ参照なので再レンダリングされない
};
// OK: イミュータブルに更新
const updateThemeGood = () => {
setUser({
...user,
preferences: {
...user.preferences,
theme: 'light',
},
});
};
return <button onClick={updateThemeGood}>テーマ変更</button>;
}3.3 useReducer: 複雑な状態遷移
// useReducer: 複雑な状態遷移
type State = { count: number; step: number; history: number[] };
type Action =
| { type: 'increment' }
| { type: 'decrement' }
| { type: 'setStep'; step: number }
| { type: 'reset' }
| { type: 'undo' };
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'increment':
return {
...state,
count: state.count + state.step,
history: [...state.history, state.count],
};
case 'decrement':
return {
...state,
count: state.count - state.step,
history: [...state.history, state.count],
};
case 'setStep':
return { ...state, step: action.step };
case 'reset':
return { count: 0, step: 1, history: [] };
case 'undo': {
const previous = state.history[state.history.length - 1];
if (previous === undefined) return state;
return {
...state,
count: previous,
history: state.history.slice(0, -1),
};
}
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, {
count: 0,
step: 1,
history: [],
});
return (
<div>
<span>{state.count}</span>
<button onClick={() => dispatch({ type: 'increment' })}>
+{state.step}
</button>
<button onClick={() => dispatch({ type: 'decrement' })}>
-{state.step}
</button>
<button onClick={() => dispatch({ type: 'undo' })}>
元に戻す
</button>
<button onClick={() => dispatch({ type: 'reset' })}>
リセット
</button>
<input
type="number"
value={state.step}
onChange={(e) =>
dispatch({ type: 'setStep', step: Number(e.target.value) })
}
/>
</div>
);
}
// useReducer を使うべき場面:
// → 3つ以上の関連する状態
// → 状態遷移のルールが複雑
// → 次の状態が前の状態に依存
// → Undo/Redo が必要
// → テストしやすくしたい(reducerは純粋関数)3.4 実務例: マルチステップフォーム
// マルチステップフォームの状態管理
type FormData = {
// Step 1: 基本情報
firstName: string;
lastName: string;
email: string;
// Step 2: 住所
postalCode: string;
prefecture: string;
city: string;
address: string;
// Step 3: 支払い
cardNumber: string;
expiryDate: string;
cvv: string;
};
type FormState = {
currentStep: number;
data: FormData;
errors: Partial<Record<keyof FormData, string>>;
isSubmitting: boolean;
completedSteps: Set<number>;
};
type FormAction =
| { type: 'UPDATE_FIELD'; field: keyof FormData; value: string }
| { type: 'SET_ERRORS'; errors: Partial<Record<keyof FormData, string>> }
| { type: 'NEXT_STEP' }
| { type: 'PREV_STEP' }
| { type: 'GO_TO_STEP'; step: number }
| { type: 'SUBMIT_START' }
| { type: 'SUBMIT_SUCCESS' }
| { type: 'SUBMIT_ERROR'; error: string };
const initialFormState: FormState = {
currentStep: 0,
data: {
firstName: '',
lastName: '',
email: '',
postalCode: '',
prefecture: '',
city: '',
address: '',
cardNumber: '',
expiryDate: '',
cvv: '',
},
errors: {},
isSubmitting: false,
completedSteps: new Set(),
};
function formReducer(state: FormState, action: FormAction): FormState {
switch (action.type) {
case 'UPDATE_FIELD':
return {
...state,
data: { ...state.data, [action.field]: action.value },
errors: { ...state.errors, [action.field]: undefined },
};
case 'SET_ERRORS':
return { ...state, errors: action.errors };
case 'NEXT_STEP':
return {
...state,
currentStep: Math.min(state.currentStep + 1, 2),
completedSteps: new Set([
...state.completedSteps,
state.currentStep,
]),
};
case 'PREV_STEP':
return {
...state,
currentStep: Math.max(state.currentStep - 1, 0),
};
case 'GO_TO_STEP':
if (action.step <= state.currentStep || state.completedSteps.has(action.step - 1)) {
return { ...state, currentStep: action.step };
}
return state;
case 'SUBMIT_START':
return { ...state, isSubmitting: true };
case 'SUBMIT_SUCCESS':
return { ...state, isSubmitting: false };
case 'SUBMIT_ERROR':
return {
...state,
isSubmitting: false,
errors: { ...state.errors },
};
default:
return state;
}
}
function MultiStepForm() {
const [state, dispatch] = useReducer(formReducer, initialFormState);
const { currentStep, data, errors, isSubmitting } = state;
const validateStep = (step: number): boolean => {
const newErrors: Partial<Record<keyof FormData, string>> = {};
if (step === 0) {
if (!data.firstName) newErrors.firstName = '名前は必須です';
if (!data.lastName) newErrors.lastName = '姓は必須です';
if (!data.email) newErrors.email = 'メールは必須です';
if (data.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) {
newErrors.email = '有効なメールアドレスを入力してください';
}
}
if (step === 1) {
if (!data.postalCode) newErrors.postalCode = '郵便番号は必須です';
if (!data.prefecture) newErrors.prefecture = '都道府県は必須です';
if (!data.city) newErrors.city = '市区町村は必須です';
}
if (step === 2) {
if (!data.cardNumber) newErrors.cardNumber = 'カード番号は必須です';
if (!data.expiryDate) newErrors.expiryDate = '有効期限は必須です';
if (!data.cvv) newErrors.cvv = 'CVVは必須です';
}
dispatch({ type: 'SET_ERRORS', errors: newErrors });
return Object.keys(newErrors).length === 0;
};
const handleNext = () => {
if (validateStep(currentStep)) {
dispatch({ type: 'NEXT_STEP' });
}
};
const handleSubmit = async () => {
if (!validateStep(currentStep)) return;
dispatch({ type: 'SUBMIT_START' });
try {
await submitOrder(data);
dispatch({ type: 'SUBMIT_SUCCESS' });
} catch (err) {
dispatch({ type: 'SUBMIT_ERROR', error: '送信に失敗しました' });
}
};
return (
<div>
<StepIndicator
currentStep={currentStep}
completedSteps={state.completedSteps}
onStepClick={(step) => dispatch({ type: 'GO_TO_STEP', step })}
/>
{currentStep === 0 && (
<BasicInfoStep data={data} errors={errors} dispatch={dispatch} />
)}
{currentStep === 1 && (
<AddressStep data={data} errors={errors} dispatch={dispatch} />
)}
{currentStep === 2 && (
<PaymentStep data={data} errors={errors} dispatch={dispatch} />
)}
<div className="navigation">
{currentStep > 0 && (
<button onClick={() => dispatch({ type: 'PREV_STEP' })}>
戻る
</button>
)}
{currentStep < 2 ? (
<button onClick={handleNext}>次へ</button>
) : (
<button onClick={handleSubmit} disabled={isSubmitting}>
{isSubmitting ? '送信中...' : '注文を確定'}
</button>
)}
</div>
</div>
);
}4. グローバル状態の選定
4.1 ライブラリ比較
ライブラリ詳細比較:
Zustand:
→ シンプル、ボイラープレート最小
→ ストア = 関数(Reduxより直感的)
→ React外からもアクセス可能
→ バンドルサイズ: ~1.1kB (gzip)
→ TypeScript対応: 優秀(型推論が自然)
→ DevTools: Redux DevTools に対応
→ ミドルウェア: persist, devtools, immer, subscribeWithSelector
→ 推奨: 中規模以上のアプリ
→ 学習コスト: 低
Jotai:
→ アトムベース(Recoilの後継的)
→ コンポーネント単位の細かい再レンダリング制御
→ バンドルサイズ: ~3.8kB (gzip)
→ TypeScript対応: 優秀(ジェネリクス活用)
→ DevTools: React DevTools の Atoms Inspector
→ 拡張: atomWithStorage, atomWithQuery, atomWithMachine
→ 推奨: 複雑なUIの状態管理
→ 学習コスト: 中
React Context:
→ React組み込み、追加依存なし
→ 頻繁に変化する値には不向き(再レンダリング問題)
→ バンドルサイズ: 0kB(React内蔵)
→ TypeScript対応: 手動の型定義が必要
→ DevTools: React DevTools で確認可能
→ 推奨: テーマ、認証情報等の低頻度更新
→ 学習コスト: 低
Redux Toolkit:
→ 最も成熟したエコシステム
→ DevTools が優秀
→ ボイラープレートが多い
→ バンドルサイズ: ~12.7kB (gzip)
→ TypeScript対応: 優秀(RTK は型推論が強力)
→ ミドルウェア: RTK Query, Thunk, Saga, Observable
→ 推奨: 大規模エンタープライズ
→ 学習コスト: 高
Valtio:
→ Proxy ベースの状態管理
→ ミュータブルな書き方が可能
→ バンドルサイズ: ~3.3kB (gzip)
→ 推奨: ミュータブルAPIを好む場合
→ 学習コスト: 低
選定フロー:
テーマ/認証/言語(低頻度更新)→ Context
中規模の共有状態 → Zustand
アトム単位の細かい制御 → Jotai
大規模 + 厳密なアーキテクチャ → Redux Toolkit
ミュータブル志向 → Valtio
4.2 各ライブラリのコード比較
// === 同じ機能を各ライブラリで実装(カウンター + テーマ) ===
// --- React Context ---
type ThemeContextType = {
theme: 'light' | 'dark';
toggleTheme: () => void;
count: number;
increment: () => void;
};
const ThemeContext = createContext<ThemeContextType | null>(null);
function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<'light' | 'dark'>('light');
const [count, setCount] = useState(0);
// useMemoでvalueをメモ化しないと、毎レンダリングで
// 新しいオブジェクトが生成され、全消費者が再レンダリングされる
const value = useMemo(
() => ({
theme,
toggleTheme: () => setTheme((t) => (t === 'light' ? 'dark' : 'light')),
count,
increment: () => setCount((c) => c + 1),
}),
[theme, count]
);
return (
<ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
);
}
// Context の問題: count が変わるとテーマだけ使うコンポーネントも再レンダリング
function ThemeOnlyComponent() {
const ctx = useContext(ThemeContext);
// count の変更でもこのコンポーネントは再レンダリングされる!
return <div className={ctx?.theme}>テーマのみ使用</div>;
}
// --- Zustand ---
import { create } from 'zustand';
interface AppStore {
theme: 'light' | 'dark';
toggleTheme: () => void;
count: number;
increment: () => void;
}
const useAppStore = create<AppStore>((set) => ({
theme: 'light',
toggleTheme: () =>
set((state) => ({
theme: state.theme === 'light' ? 'dark' : 'light',
})),
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}));
// Zustand: セレクターで必要な値だけ取得 → 最小限の再レンダリング
function ThemeOnlyComponentZustand() {
const theme = useAppStore((state) => state.theme);
// count が変わってもこのコンポーネントは再レンダリングされない!
return <div className={theme}>テーマのみ使用</div>;
}
// --- Jotai ---
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai';
const themeAtom = atom<'light' | 'dark'>('light');
const countAtom = atom(0);
// 派生アトム
const themeClassAtom = atom((get) => {
const theme = get(themeAtom);
return theme === 'dark' ? 'bg-gray-900 text-white' : 'bg-white text-black';
});
function ThemeOnlyComponentJotai() {
const themeClass = useAtomValue(themeClassAtom);
// countAtom の変更でこのコンポーネントは再レンダリングされない!
return <div className={themeClass}>テーマのみ使用</div>;
}
function CounterJotai() {
const [count, setCount] = useAtom(countAtom);
return (
<button onClick={() => setCount((c) => c + 1)}>
Count: {count}
</button>
);
}4.3 Context の再レンダリング問題と対策
// Context の再レンダリング問題を理解する
// NG: 1つのContextに全状態を入れる
const AppContext = createContext<{
user: User | null;
theme: Theme;
notifications: Notification[];
sidebarOpen: boolean;
} | null>(null);
// → notificationsが更新されると、themeだけ使うコンポーネントも再レンダリング
// OK: Contextを分割する
const UserContext = createContext<User | null>(null);
const ThemeContext = createContext<Theme>('light');
const NotificationContext = createContext<Notification[]>([]);
const SidebarContext = createContext<{
isOpen: boolean;
toggle: () => void;
}>({
isOpen: false,
toggle: () => {},
});
// さらに良い: 状態と更新関数を分離
const ThemeValueContext = createContext<Theme>('light');
const ThemeDispatchContext = createContext<(theme: Theme) => void>(() => {});
function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>('light');
return (
<ThemeValueContext.Provider value={theme}>
<ThemeDispatchContext.Provider value={setTheme}>
{children}
</ThemeDispatchContext.Provider>
</ThemeValueContext.Provider>
);
}
// テーマの値だけ必要なコンポーネント
function ThemedComponent() {
const theme = useContext(ThemeValueContext);
// setThemeが変わっても再レンダリングされない
return <div className={theme}>テーマ適用済み</div>;
}
// テーマの変更だけ行うコンポーネント
function ThemeToggle() {
const setTheme = useContext(ThemeDispatchContext);
// theme値が変わっても再レンダリングされない
return <button onClick={() => setTheme('dark')}>ダークモード</button>;
}5. サーバー状態の概要
// サーバー状態を useState で管理する場合の問題点
function UserListBad() {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
fetchUsers()
.then((data) => {
if (!cancelled) {
setUsers(data);
setLoading(false);
}
})
.catch((err) => {
if (!cancelled) {
setError(err);
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, []);
// 問題点:
// → キャッシュなし(他のコンポーネントで同じデータが必要な場合、再取得が発生)
// → 自動再検証なし(データが古くなっても気づけない)
// → リトライロジックなし
// → ローディング/エラー状態の管理が手動
// → 重複リクエストの抑制なし
// → 楽観的更新の実装が困難
// → Suspense非対応
// → ウィンドウフォーカス時の再取得なし
return loading ? <Spinner /> : <UserTable users={users} />;
}
// TanStack Query で同じことを実現
function UserListGood() {
const { data: users, isLoading, error } = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
staleTime: 5 * 60 * 1000, // 5分間キャッシュを新鮮とみなす
retry: 3,
refetchOnWindowFocus: true,
});
// 自動で得られる機能:
// ✓ キャッシュ(他のコンポーネントから同じqueryKeyで取得 → キャッシュから即座に返す)
// ✓ 自動再検証(staleTime経過後、バックグラウンドで再取得)
// ✓ リトライ(失敗時に自動リトライ)
// ✓ ローディング/エラー状態の自動管理
// ✓ 重複リクエストの自動抑制
// ✓ ウィンドウフォーカス時の再取得
// ✓ Suspense対応
if (isLoading) return <Spinner />;
if (error) return <ErrorMessage error={error} />;
return <UserTable users={users!} />;
}5.1 サーバー状態の特殊性
サーバー状態がクライアント状態と本質的に異なる点:
① 所有権がサーバーにある:
→ クライアントが持つのは「スナップショット」にすぎない
→ 別のユーザーがサーバー上のデータを変更する可能性がある
→ 定期的な再検証(revalidation)が必要
② 非同期で取得する:
→ ローディング状態が常に存在する
→ ネットワークエラーの可能性
→ レイテンシーの考慮
③ キャッシュの概念が必要:
→ 同じデータを何度も取得するのは無駄
→ しかしキャッシュが古くなる問題
→ stale-while-revalidate パターン
④ 楽観的更新が有用:
→ ユーザー操作に即座に反映 → UX向上
→ サーバー応答後に整合性を確認
→ エラー時はロールバック
stale-while-revalidate パターン:
1. キャッシュにデータがあれば即座に返す(stale data)
2. バックグラウンドで最新データを取得する(revalidate)
3. 最新データが取得できたらキャッシュを更新してUIに反映
ユーザー体験:
→ 初回: ローディング → データ表示
→ 2回目以降: 即座にキャッシュ表示 → (バックグラウンド更新) → 最新データに切り替え
→ ユーザーは「一瞬で表示される」と感じる
6. URL状態の概要
// URL状態をuseStateで管理した場合の問題
function SearchPageBad() {
const [query, setQuery] = useState('');
const [category, setCategory] = useState('all');
const [page, setPage] = useState(1);
// 問題:
// → URLに反映されない → ブックマーク不可
// → ブラウザの戻る/進むで状態が復元されない
// → URLをコピーして共有しても検索条件が再現されない
// → SEO的にも不利
return (
<div>
<SearchInput value={query} onChange={setQuery} />
<CategoryFilter value={category} onChange={setCategory} />
<Pagination page={page} onChange={setPage} />
</div>
);
}
// URL状態として管理した場合
function SearchPageGood() {
const [searchParams, setSearchParams] = useSearchParams();
const query = searchParams.get('q') ?? '';
const category = searchParams.get('category') ?? 'all';
const page = Number(searchParams.get('page') ?? '1');
const updateParams = (updates: Record<string, string>) => {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
Object.entries(updates).forEach(([key, value]) => {
if (value) {
next.set(key, value);
} else {
next.delete(key);
}
});
return next;
});
};
// メリット:
// ✓ URL: /search?q=react&category=books&page=2
// ✓ ブックマーク可能
// ✓ ブラウザの戻る/進むで状態が復元される
// ✓ URLを共有すれば検索条件が再現される
// ✓ SSR/SSGでの初期値として利用可能
return (
<div>
<SearchInput
value={query}
onChange={(q) => updateParams({ q, page: '1' })}
/>
<CategoryFilter
value={category}
onChange={(c) => updateParams({ category: c, page: '1' })}
/>
<Pagination
page={page}
onChange={(p) => updateParams({ page: String(p) })}
/>
</div>
);
}
// nuqs を使ったより型安全なURL状態管理
import { useQueryState, parseAsInteger, parseAsString } from 'nuqs';
function SearchPageNuqs() {
const [query, setQuery] = useQueryState('q', parseAsString.withDefault(''));
const [category, setCategory] = useQueryState(
'category',
parseAsString.withDefault('all')
);
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1));
// nuqs のメリット:
// ✓ 型安全(parseAsInteger は自動的に数値型)
// ✓ デフォルト値の指定が簡潔
// ✓ Next.js App Router との深い統合
// ✓ サーバーコンポーネントからの初期値渡しに対応
// ✓ 浅いルーティング(ナビゲーションなしでURL更新)
return (
<div>
<SearchInput value={query} onChange={setQuery} />
<CategoryFilter value={category} onChange={setCategory} />
<Pagination page={page} onChange={setPage} />
</div>
);
}6.1 URL状態にすべきもの、すべきでないもの
URL状態にすべきもの:
✓ 検索クエリ(?q=react)
✓ フィルタ条件(?category=books&price=low)
✓ ソート順(?sort=price&order=asc)
✓ ページ番号(?page=3)
✓ 表示モード(?view=grid)
✓ タブ選択(?tab=settings)
✓ 日付範囲(?from=2024-01-01&to=2024-12-31)
✓ 選択中のアイテムID(/items/123)
URL状態にすべきでないもの:
✗ フォームの入力途中のデータ
✗ モーダルの開閉状態(議論あり、場合による)
✗ ホバー状態、ドラッグ状態
✗ アニメーション状態
✗ 認証トークン
✗ 一時的なエラーメッセージ
✗ 大量のデータ(URLの長さ制限)
判断基準:
「そのページをブックマークして後で開いた時、
その状態が復元されるべきか?」
→ Yes → URL状態
→ No → ローカル or グローバル状態
7. 設計原則
7.1 状態の最小化
// 原則①: 状態の最小化 — 計算できる値は状態にしない
// NG: 冗長な状態
function CartBad() {
const [items, setItems] = useState<CartItem[]>([]);
const [totalPrice, setTotalPrice] = useState(0); // items から計算可能
const [itemCount, setItemCount] = useState(0); // items から計算可能
const [isEmpty, setIsEmpty] = useState(true); // items から計算可能
// items を更新するたびに他の3つも同期する必要がある → バグの温床
const addItem = (item: CartItem) => {
const newItems = [...items, item];
setItems(newItems);
setTotalPrice(newItems.reduce((sum, i) => sum + i.price * i.quantity, 0));
setItemCount(newItems.reduce((sum, i) => sum + i.quantity, 0));
setIsEmpty(false);
// 1つでも更新を忘れると不整合が発生
};
return <div>{totalPrice}</div>;
}
// OK: 1つの状態から導出
function CartGood() {
const [items, setItems] = useState<CartItem[]>([]);
// 導出値: useMemo で計算(items が変わった時だけ再計算)
const totalPrice = useMemo(
() => items.reduce((sum, i) => sum + i.price * i.quantity, 0),
[items]
);
const itemCount = useMemo(
() => items.reduce((sum, i) => sum + i.quantity, 0),
[items]
);
const isEmpty = items.length === 0; // 軽い計算は useMemo 不要
const addItem = (item: CartItem) => {
setItems((prev) => [...prev, item]);
// totalPrice, itemCount は自動で再計算される → 不整合が起きない
};
return <div>{totalPrice}</div>;
}7.2 Derived State(導出状態)
// 原則②: Derived State
// NG: 同期が必要な冗長な状態
function ProductListBad() {
const [products, setProducts] = useState<Product[]>([]);
const [filteredProducts, setFilteredProducts] = useState<Product[]>([]);
const [sortedProducts, setSortedProducts] = useState<Product[]>([]);
const [filter, setFilter] = useState('');
const [sortBy, setSortBy] = useState<'name' | 'price'>('name');
// products, filter, sortBy のどれが変わっても
// filteredProducts と sortedProducts を手動で更新する必要がある
useEffect(() => {
const filtered = products.filter((p) =>
p.name.toLowerCase().includes(filter.toLowerCase())
);
setFilteredProducts(filtered);
}, [products, filter]);
useEffect(() => {
const sorted = [...filteredProducts].sort((a, b) =>
sortBy === 'name'
? a.name.localeCompare(b.name)
: a.price - b.price
);
setSortedProducts(sorted);
}, [filteredProducts, sortBy]);
// 問題: useEffect の連鎖 → 理解しづらい、バグが生まれやすい
return <ProductGrid products={sortedProducts} />;
}
// OK: 導出値として計算
function ProductListGood() {
const [products, setProducts] = useState<Product[]>([]);
const [filter, setFilter] = useState('');
const [sortBy, setSortBy] = useState<'name' | 'price'>('name');
// 状態は3つだけ。表示用データは計算で得る
const displayProducts = useMemo(() => {
return products
.filter((p) => p.name.toLowerCase().includes(filter.toLowerCase()))
.sort((a, b) =>
sortBy === 'name'
? a.name.localeCompare(b.name)
: a.price - b.price
);
}, [products, filter, sortBy]);
return <ProductGrid products={displayProducts} />;
}7.3 Colocate State
// 原則③: Colocate State(状態を使う場所の近くに配置)
// NG: 不必要にグローバルにした状態
// store.ts
const useStore = create<{
modalOpen: boolean; // ← 1つのコンポーネントでしか使わない
tooltipText: string; // ← 1つのコンポーネントでしか使わない
dropdownItems: string[]; // ← 1つのコンポーネントでしか使わない
searchQuery: string; // ← 実際に共有が必要
user: User | null; // ← 実際に共有が必要
}>((set) => ({
// ...
}));
// OK: ローカルにすべきものはローカルに
function Modal() {
const [isOpen, setIsOpen] = useState(false); // ローカルで十分
return (
<>
<button onClick={() => setIsOpen(true)}>開く</button>
{isOpen && <ModalDialog onClose={() => setIsOpen(false)} />}
</>
);
}
// グローバルストアには本当に共有が必要なものだけ
const useStore = create<{
searchQuery: string;
user: User | null;
}>((set) => ({
searchQuery: '',
user: null,
}));7.4 Props Drilling とコンポジション
// 原則④: Props Drilling の許容範囲と代替手段
// Props Drilling: 2-3階層は許容
function App() {
const [user, setUser] = useState<User | null>(null);
return <Dashboard user={user} />;
}
function Dashboard({ user }: { user: User | null }) {
return <Header user={user} />;
}
function Header({ user }: { user: User | null }) {
return <UserMenu user={user} />;
}
// 4階層以上の場合 → コンポジションで解決
// コンポジションパターン: children を使って中間コンポーネントを「飛ばす」
function App() {
const [user, setUser] = useState<User | null>(null);
return (
<Dashboard>
<Header>
<UserMenu user={user} />
</Header>
</Dashboard>
);
}
function Dashboard({ children }: { children: React.ReactNode }) {
return <div className="dashboard">{children}</div>;
}
function Header({ children }: { children: React.ReactNode }) {
return <header>{children}</header>;
}
// → Dashboard と Header は user を知る必要がない
// → UserMenu だけが user を受け取る
// → Props Drilling が解消される7.5 Single Source of Truth
// 原則⑤: Single Source of Truth
// NG: 同じユーザーデータを複数箇所で管理
function App() {
// ヘッダー表示用
const [headerUser, setHeaderUser] = useState<User | null>(null);
// プロフィールページ用
const [profileUser, setProfileUser] = useState<User | null>(null);
// 設定ページ用
const [settingsUser, setSettingsUser] = useState<User | null>(null);
// → 1つ更新して他を忘れると不整合
return <div>...</div>;
}
// OK: TanStack Query でサーバーデータを一元管理
function useCurrentUser() {
return useQuery({
queryKey: ['currentUser'],
queryFn: fetchCurrentUser,
staleTime: 5 * 60 * 1000,
});
}
// どのコンポーネントから呼んでも同じキャッシュを参照
function Header() {
const { data: user } = useCurrentUser();
return <div>{user?.name}</div>;
}
function ProfilePage() {
const { data: user } = useCurrentUser();
return <div>{user?.email}</div>;
}
function SettingsPage() {
const { data: user } = useCurrentUser();
const queryClient = useQueryClient();
const updateUser = async (data: Partial<User>) => {
await api.updateUser(data);
// キャッシュを無効化 → 全コンポーネントが最新データに
queryClient.invalidateQueries({ queryKey: ['currentUser'] });
};
return <UserSettingsForm user={user!} onSave={updateUser} />;
}7.6 不変性(Immutability)
// 原則⑥: 不変性(Immutability)
// NG: 直接変更
function TodoListBad() {
const [todos, setTodos] = useState<Todo[]>([]);
const toggleTodo = (id: string) => {
const todo = todos.find((t) => t.id === id);
if (todo) {
todo.completed = !todo.completed; // 直接変更!
setTodos([...todos]); // スプレッドしても元のオブジェクトは変更済み
}
};
return <div>{/* ... */}</div>;
}
// OK: イミュータブルに更新
function TodoListGood() {
const [todos, setTodos] = useState<Todo[]>([]);
const toggleTodo = (id: string) => {
setTodos((prev) =>
prev.map((todo) =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
);
};
const addTodo = (text: string) => {
setTodos((prev) => [
...prev,
{ id: crypto.randomUUID(), text, completed: false },
]);
};
const removeTodo = (id: string) => {
setTodos((prev) => prev.filter((todo) => todo.id !== id));
};
return <div>{/* ... */}</div>;
}
// ネストが深い場合は Immer を活用
import { produce } from 'immer';
function NestedStateUpdate() {
const [state, setState] = useState({
users: {
byId: {
'1': {
name: 'Taro',
address: {
city: 'Tokyo',
zip: '100-0001',
},
},
},
},
});
// Immer なし: スプレッドの嵐
const updateCityManual = () => {
setState({
...state,
users: {
...state.users,
byId: {
...state.users.byId,
'1': {
...state.users.byId['1'],
address: {
...state.users.byId['1'].address,
city: 'Osaka',
},
},
},
},
});
};
// Immer あり: 直感的な書き方
const updateCityImmer = () => {
setState(
produce((draft) => {
draft.users.byId['1'].address.city = 'Osaka';
})
);
};
return <div>{/* ... */}</div>;
}8. パフォーマンス最適化
8.1 再レンダリングの理解
// React の再レンダリングが発生する条件
// 1. state が変更された
// 2. props が変更された
// 3. 親コンポーネントが再レンダリングされた
// 4. コンテキストの値が変更された
// 再レンダリングの最適化テクニック
// ① React.memo: props が変わらなければ再レンダリングをスキップ
const ExpensiveList = React.memo(function ExpensiveList({
items,
}: {
items: Item[];
}) {
console.log('ExpensiveList rendered');
return (
<ul>
{items.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
);
});
// ② useMemo: 計算結果をメモ化
function Dashboard({ orders }: { orders: Order[] }) {
// orders が変わった時だけ再計算
const stats = useMemo(() => {
return {
total: orders.length,
revenue: orders.reduce((sum, o) => sum + o.total, 0),
averageOrder: orders.reduce((sum, o) => sum + o.total, 0) / orders.length,
byStatus: orders.reduce(
(acc, o) => {
acc[o.status] = (acc[o.status] || 0) + 1;
return acc;
},
{} as Record<string, number>
),
};
}, [orders]);
return (
<div>
<StatCard title="総注文数" value={stats.total} />
<StatCard title="売上" value={stats.revenue} />
<StatCard title="平均注文額" value={stats.averageOrder} />
</div>
);
}
// ③ useCallback: コールバックをメモ化
function ParentComponent() {
const [count, setCount] = useState(0);
const [text, setText] = useState('');
// useCallback なし: text が変わるたびに新しい関数が生成
// → ChildComponent が React.memo でも再レンダリングされる
const handleClickBad = () => {
setCount((c) => c + 1);
};
// useCallback あり: count の変更時のみ新しい関数
const handleClickGood = useCallback(() => {
setCount((c) => c + 1);
}, []);
return (
<div>
<input value={text} onChange={(e) => setText(e.target.value)} />
<MemoizedChild onClick={handleClickGood} count={count} />
</div>
);
}
const MemoizedChild = React.memo(function Child({
onClick,
count,
}: {
onClick: () => void;
count: number;
}) {
console.log('Child rendered');
return <button onClick={onClick}>Count: {count}</button>;
});8.2 状態の構造とパフォーマンス
// 状態の構造がパフォーマンスに与える影響
// NG: フラットな巨大配列 → 1つの変更で全体が再レンダリング
function BigListBad() {
const [items, setItems] = useState<Item[]>(generateItems(10000));
const toggleItem = (id: string) => {
setItems((prev) =>
prev.map((item) =>
item.id === id ? { ...item, selected: !item.selected } : item
)
);
// 10000要素の配列を全部mapして新しい配列を作成
// → items が新しい参照 → リスト全体が再レンダリング
};
return (
<ul>
{items.map((item) => (
<li key={item.id} onClick={() => toggleItem(item.id)}>
{item.name}
</li>
))}
</ul>
);
}
// OK: 正規化されたデータ構造 + React.memo
function BigListGood() {
const [itemsById, setItemsById] = useState<Record<string, Item>>({});
const [itemIds, setItemIds] = useState<string[]>([]);
const toggleItem = useCallback((id: string) => {
setItemsById((prev) => ({
...prev,
[id]: { ...prev[id], selected: !prev[id].selected },
}));
// 変更されたアイテムのみ新しいオブジェクトが生成される
}, []);
return (
<ul>
{itemIds.map((id) => (
<MemoizedItem
key={id}
id={id}
item={itemsById[id]}
onToggle={toggleItem}
/>
))}
</ul>
);
}
const MemoizedItem = React.memo(function ItemRow({
id,
item,
onToggle,
}: {
id: string;
item: Item;
onToggle: (id: string) => void;
}) {
return (
<li onClick={() => onToggle(id)}>
{item.name} {item.selected ? '✓' : ''}
</li>
);
});
// → 変更されたアイテムのみ再レンダリング8.3 仮想化(Virtualization)
// 大量リストのパフォーマンス対策: 仮想化
import { useVirtualizer } from '@tanstack/react-virtual';
function VirtualizedList({ items }: { items: Item[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 50, // 各行の推定高さ(px)
overscan: 5, // ビューポート外に余分にレンダリングする行数
});
return (
<div
ref={parentRef}
style={{ height: '400px', overflow: 'auto' }}
>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map((virtualRow) => (
<div
key={virtualRow.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
{items[virtualRow.index].name}
</div>
))}
</div>
</div>
);
// 10000アイテムでも、画面に表示される分(+ overscan)だけレンダリング
// → DOMノード数を大幅に削減
}9. 実務での状態管理アーキテクチャ
9.1 小規模アプリ(〜10ページ)
推奨構成:
- ローカル状態: useState / useReducer
- サーバー状態: TanStack Query
- URL状態: useSearchParams
- グローバル状態: React Context(必要な場合のみ)
ディレクトリ構成:
src/
├── components/
│ ├── Header.tsx // ローカル状態のみ
│ └── SearchForm.tsx // ローカル + URL状態
├── hooks/
│ ├── useUsers.ts // TanStack Query
│ └── useAuth.ts // TanStack Query + Context
├── contexts/
│ └── AuthContext.tsx // 認証状態
└── pages/
└── UsersPage.tsx // URL状態 + サーバー状態
特徴:
→ 追加ライブラリは TanStack Query のみ
→ Context は認証やテーマなど1-2個
→ シンプルで学習コストが低い
9.2 中規模アプリ(10〜50ページ)
推奨構成:
- ローカル状態: useState / useReducer
- サーバー状態: TanStack Query
- URL状態: nuqs
- グローバル状態: Zustand
ディレクトリ構成:
src/
├── components/
├── hooks/
│ ├── queries/ // TanStack Query のカスタムフック
│ │ ├── useUsers.ts
│ │ ├── useProducts.ts
│ │ └── useOrders.ts
│ └── mutations/ // TanStack Query のミューテーション
│ ├── useCreateUser.ts
│ └── useUpdateProduct.ts
├── stores/ // Zustand ストア
│ ├── useUIStore.ts // UI状態(サイドバー、モーダル等)
│ ├── useCartStore.ts // カート状態
│ └── usePreferenceStore.ts // ユーザー設定
└── pages/
特徴:
→ Zustand でクライアント状態を効率的に管理
→ TanStack Query でサーバー状態を一元管理
→ nuqs で型安全なURL状態管理
→ 明確な責務分離
9.3 大規模アプリ(50ページ以上)
推奨構成:
- ローカル状態: useState / useReducer
- サーバー状態: TanStack Query
- URL状態: nuqs
- グローバル状態: Zustand(ドメイン分割)
- フォーム状態: React Hook Form + Zod
ディレクトリ構成:
src/
├── features/ // 機能ベースのモジュール分割
│ ├── auth/
│ │ ├── hooks/
│ │ │ ├── useLogin.ts
│ │ │ └── useCurrentUser.ts
│ │ ├── stores/
│ │ │ └── useAuthStore.ts
│ │ └── components/
│ ├── products/
│ │ ├── hooks/
│ │ │ ├── queries/
│ │ │ └── mutations/
│ │ ├── stores/
│ │ └── components/
│ └── orders/
│ ├── hooks/
│ ├── stores/
│ └── components/
├── shared/
│ ├── stores/ // アプリ全体で共有する状態
│ │ └── useUIStore.ts
│ └── hooks/
│ └── useSearchParams.ts
└── lib/
├── queryClient.ts // TanStack Query の設定
└── api.ts // API クライアント
特徴:
→ 機能ベースのモジュール分割で責務を明確化
→ 各機能が独自のストア、フック、コンポーネントを持つ
→ 共有状態は shared/ に集約
→ チーム開発でのコンフリクトを最小化
9.4 状態管理の実装パターン集
// パターン1: カスタムフックで状態ロジックをカプセル化
function useToggle(initialValue = false) {
const [value, setValue] = useState(initialValue);
const toggle = useCallback(() => setValue((v) => !v), []);
const setTrue = useCallback(() => setValue(true), []);
const setFalse = useCallback(() => setValue(false), []);
return { value, toggle, setTrue, setFalse } as const;
}
// 使用例
function Sidebar() {
const { value: isOpen, toggle, setFalse: close } = useToggle(false);
return (
<>
<button onClick={toggle}>メニュー</button>
{isOpen && <SidebarContent onClose={close} />}
</>
);
}
// パターン2: useReducer + Context で Domain-Specific な状態管理
type CartState = {
items: CartItem[];
discount: number;
};
type CartAction =
| { type: 'ADD_ITEM'; item: Product; quantity: number }
| { type: 'REMOVE_ITEM'; productId: string }
| { type: 'UPDATE_QUANTITY'; productId: string; quantity: number }
| { type: 'APPLY_DISCOUNT'; code: string; discount: number }
| { type: 'CLEAR' };
function cartReducer(state: CartState, action: CartAction): CartState {
switch (action.type) {
case 'ADD_ITEM': {
const existing = state.items.find(
(i) => i.productId === action.item.id
);
if (existing) {
return {
...state,
items: state.items.map((i) =>
i.productId === action.item.id
? { ...i, quantity: i.quantity + action.quantity }
: i
),
};
}
return {
...state,
items: [
...state.items,
{
productId: action.item.id,
name: action.item.name,
price: action.item.price,
quantity: action.quantity,
},
],
};
}
case 'REMOVE_ITEM':
return {
...state,
items: state.items.filter((i) => i.productId !== action.productId),
};
case 'UPDATE_QUANTITY':
return {
...state,
items: state.items.map((i) =>
i.productId === action.productId
? { ...i, quantity: action.quantity }
: i
),
};
case 'APPLY_DISCOUNT':
return { ...state, discount: action.discount };
case 'CLEAR':
return { items: [], discount: 0 };
}
}
// パターン3: Zustand のスライスパターン
interface UserSlice {
user: User | null;
setUser: (user: User | null) => void;
logout: () => void;
}
interface UISlice {
sidebarOpen: boolean;
toggleSidebar: () => void;
theme: 'light' | 'dark';
setTheme: (theme: 'light' | 'dark') => void;
}
interface NotificationSlice {
notifications: Notification[];
addNotification: (notification: Notification) => void;
removeNotification: (id: string) => void;
clearAll: () => void;
}
// スライスを結合
type AppStore = UserSlice & UISlice & NotificationSlice;
const useAppStore = create<AppStore>()((...a) => ({
...createUserSlice(...a),
...createUISlice(...a),
...createNotificationSlice(...a),
}));
// 各スライスは別ファイルで定義
// stores/userSlice.ts
const createUserSlice: StateCreator<AppStore, [], [], UserSlice> = (set) => ({
user: null,
setUser: (user) => set({ user }),
logout: () => set({ user: null }),
});
// stores/uiSlice.ts
const createUISlice: StateCreator<AppStore, [], [], UISlice> = (set) => ({
sidebarOpen: false,
toggleSidebar: () =>
set((state) => ({ sidebarOpen: !state.sidebarOpen })),
theme: 'light',
setTheme: (theme) => set({ theme }),
});10. React 19 と状態管理の進化
10.1 useActionState(旧 useFormState)
// React 19 の useActionState
import { useActionState } from 'react';
type FormState = {
error: string | null;
success: boolean;
};
async function submitAction(
prevState: FormState,
formData: FormData
): Promise<FormState> {
const email = formData.get('email') as string;
const password = formData.get('password') as string;
try {
await login(email, password);
return { error: null, success: true };
} catch (err) {
return { error: 'ログインに失敗しました', success: false };
}
}
function LoginForm() {
const [state, action, isPending] = useActionState(submitAction, {
error: null,
success: false,
});
return (
<form action={action}>
<input name="email" type="email" />
<input name="password" type="password" />
{state.error && <p className="error">{state.error}</p>}
<button type="submit" disabled={isPending}>
{isPending ? 'ログイン中...' : 'ログイン'}
</button>
</form>
);
}10.2 useOptimistic
// React 19 の useOptimistic
import { useOptimistic, useTransition } from 'react';
function TodoList() {
const [todos, setTodos] = useState<Todo[]>([]);
const [optimisticTodos, addOptimisticTodo] = useOptimistic(
todos,
(state, newTodo: Todo) => [...state, newTodo]
);
const [isPending, startTransition] = useTransition();
const addTodo = async (text: string) => {
const newTodo: Todo = {
id: crypto.randomUUID(),
text,
completed: false,
};
startTransition(async () => {
// 楽観的にUIを更新(即座に表示)
addOptimisticTodo(newTodo);
// サーバーに送信
const savedTodo = await api.createTodo(newTodo);
// サーバーの応答で実際のデータに置き換え
setTodos((prev) => [...prev, savedTodo]);
});
};
return (
<div>
<AddTodoForm onAdd={addTodo} />
<ul>
{optimisticTodos.map((todo) => (
<li key={todo.id}>{todo.text}</li>
))}
</ul>
</div>
);
}10.3 use() フック
// React 19 の use() フック
import { use, Suspense } from 'react';
// Promise を直接読み取る
function UserProfile({ userPromise }: { userPromise: Promise<User> }) {
const user = use(userPromise);
// Suspense が自動で Loading 状態をハンドル
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
}
function App() {
const userPromise = fetchUser(1); // Promiseを渡す(awaitしない)
return (
<Suspense fallback={<Spinner />}>
<UserProfile userPromise={userPromise} />
</Suspense>
);
}
// Context を条件付きで読み取る(use は if 文の中で使える)
function ConditionalTheme({ useTheme }: { useTheme: boolean }) {
// 従来の useContext はトップレベルでしか呼べなかった
// use() は条件分岐の中で使える
if (useTheme) {
const theme = use(ThemeContext);
return <div className={theme}>テーマ適用</div>;
}
return <div>デフォルト</div>;
}11. テスト戦略
11.1 状態管理のテスト
// useReducer のテスト(純粋関数なので簡単)
describe('formReducer', () => {
const initialState: FormState = {
currentStep: 0,
data: { firstName: '', lastName: '', email: '' },
errors: {},
isSubmitting: false,
};
it('should update a field', () => {
const result = formReducer(initialState, {
type: 'UPDATE_FIELD',
field: 'firstName',
value: 'Taro',
});
expect(result.data.firstName).toBe('Taro');
// エラーがクリアされることも確認
expect(result.errors.firstName).toBeUndefined();
});
it('should advance to next step', () => {
const result = formReducer(initialState, { type: 'NEXT_STEP' });
expect(result.currentStep).toBe(1);
});
it('should not go below step 0', () => {
const result = formReducer(initialState, { type: 'PREV_STEP' });
expect(result.currentStep).toBe(0);
});
});
// Zustand ストアのテスト
import { act, renderHook } from '@testing-library/react';
describe('useCartStore', () => {
beforeEach(() => {
// テスト間でストアをリセット
useCartStore.setState({ items: [], discount: 0 });
});
it('should add an item to the cart', () => {
const { result } = renderHook(() => useCartStore());
act(() => {
result.current.addItem({
id: '1',
name: 'テスト商品',
price: 1000,
});
});
expect(result.current.items).toHaveLength(1);
expect(result.current.items[0].name).toBe('テスト商品');
});
it('should calculate total correctly', () => {
const { result } = renderHook(() => useCartStore());
act(() => {
result.current.addItem({ id: '1', name: '商品A', price: 1000 });
result.current.addItem({ id: '2', name: '商品B', price: 2000 });
});
expect(result.current.total).toBe(3000);
});
});
// TanStack Query のテスト
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { renderHook, waitFor } from '@testing-library/react';
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}
describe('useUsers', () => {
it('should fetch users successfully', async () => {
// MSW でAPIをモック
server.use(
http.get('/api/users', () => {
return HttpResponse.json([
{ id: '1', name: 'Taro' },
{ id: '2', name: 'Hanako' },
]);
})
);
const { result } = renderHook(() => useUsers(), {
wrapper: createWrapper(),
});
// 初期状態: ローディング
expect(result.current.isLoading).toBe(true);
// データ取得完了を待つ
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data).toHaveLength(2);
expect(result.current.data![0].name).toBe('Taro');
});
});12. よくあるアンチパターンと解決策
12.1 useEffect での状態同期
// アンチパターン①: useEffect で状態を同期する
// NG: props を state にコピー
function UserProfile({ user }: { user: User }) {
const [name, setName] = useState(user.name);
// props が変わったら state を更新...
useEffect(() => {
setName(user.name);
}, [user.name]);
// → 1フレーム遅れる、不要な再レンダリング
return <div>{name}</div>;
}
// OK: key を使ってコンポーネントをリセット
function UserProfilePage({ userId }: { userId: string }) {
return <EditableUserProfile key={userId} userId={userId} />;
}
function EditableUserProfile({ userId }: { userId: string }) {
const { data: user } = useUser(userId);
const [name, setName] = useState(user?.name ?? '');
// key が変わるとコンポーネント全体がリマウントされ、stateがリセットされる
return <input value={name} onChange={(e) => setName(e.target.value)} />;
}
// アンチパターン②: useEffect の連鎖
// NG: 「useEffect → setState → 別のuseEffect → setState ...」
function FilteredListBad() {
const [items, setItems] = useState<Item[]>([]);
const [filter, setFilter] = useState('');
const [filtered, setFiltered] = useState<Item[]>([]);
const [sorted, setSorted] = useState<Item[]>([]);
useEffect(() => {
setFiltered(items.filter((i) => i.name.includes(filter)));
}, [items, filter]);
useEffect(() => {
setSorted([...filtered].sort((a, b) => a.name.localeCompare(b.name)));
}, [filtered]);
// → 3回のレンダリングが発生(items変更 → filtered変更 → sorted変更)
return <List items={sorted} />;
}
// OK: useMemo で同期的に計算
function FilteredListGood() {
const [items, setItems] = useState<Item[]>([]);
const [filter, setFilter] = useState('');
const displayItems = useMemo(() => {
return items
.filter((i) => i.name.includes(filter))
.sort((a, b) => a.name.localeCompare(b.name));
}, [items, filter]);
// → 1回のレンダリングで完結
return <List items={displayItems} />;
}12.2 グローバルストアの肥大化
// アンチパターン③: 何でもグローバルストアに入れる
// NG: 巨大な単一ストア
const useMegaStore = create<{
// UI状態
sidebarOpen: boolean;
modalOpen: boolean;
activeTab: string;
tooltipText: string;
dropdownOpen: boolean;
// ユーザー状態
user: User | null;
isAuthenticated: boolean;
// 商品状態
products: Product[];
selectedProduct: Product | null;
// カート状態
cartItems: CartItem[];
cartTotal: number;
// 検索状態
searchQuery: string;
searchResults: Product[];
// 通知状態
notifications: Notification[];
// ... 50以上のプロパティ
}>((set) => ({
// ... 膨大なアクション定義
}));
// OK: 関心ごとに分割
const useUIStore = create<UIStore>((set) => ({
sidebarOpen: false,
toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
}));
const useCartStore = create<CartStore>((set, get) => ({
items: [],
addItem: (item) => set((s) => ({ items: [...s.items, item] })),
get total() {
return get().items.reduce((sum, i) => sum + i.price * i.quantity, 0);
},
}));
// サーバーデータは TanStack Query に任せる(ストアに入れない)
function useProducts() {
return useQuery({ queryKey: ['products'], queryFn: fetchProducts });
}12.3 不要な状態の保持
// アンチパターン④: propsを状態にコピーする
// NG
function UserCard({ user }: { user: User }) {
const [name, setName] = useState(user.name);
const [email, setEmail] = useState(user.email);
// → user.name や user.email が変わっても反映されない(初期値として1回だけ使われる)
return (
<div>
<p>{name}</p>
<p>{email}</p>
</div>
);
}
// OK: props をそのまま使う
function UserCard({ user }: { user: User }) {
return (
<div>
<p>{user.name}</p>
<p>{user.email}</p>
</div>
);
}
// 編集機能がある場合は、編集中の値だけ状態にする
function EditableUserCard({ user, onSave }: {
user: User;
onSave: (data: Partial<User>) => void;
}) {
const [isEditing, setIsEditing] = useState(false);
const [editName, setEditName] = useState('');
const startEditing = () => {
setEditName(user.name); // 編集開始時に初期値をセット
setIsEditing(true);
};
const save = () => {
onSave({ name: editName });
setIsEditing(false);
};
return (
<div>
{isEditing ? (
<>
<input value={editName} onChange={(e) => setEditName(e.target.value)} />
<button onClick={save}>保存</button>
<button onClick={() => setIsEditing(false)}>キャンセル</button>
</>
) : (
<>
<p>{user.name}</p>
<button onClick={startEditing}>編集</button>
</>
)}
</div>
);
}13. 状態管理のチェックリスト
プロジェクト開始時の状態管理チェックリスト:
□ 状態のカテゴリ分類を行ったか
- ローカル、グローバル、サーバー、URLの4分類
□ サーバー状態には TanStack Query / SWR を使っているか
- useState + useEffect でのデータフェッチはNG
□ URL状態を適切に使っているか
- ブックマーク/共有可能にすべき状態はURLに
□ グローバル状態は本当に必要か
- ローカルで済むものをグローバルにしていないか
□ 導出値を状態にしていないか
- 既存の状態から計算可能な値は useMemo で
□ 再レンダリングの最適化は適切か
- Context の分割、セレクター、React.memo
□ テスト可能な設計になっているか
- Reducer は純粋関数、ストアはリセット可能
□ TypeScript で型安全か
- any を使っていないか、discriminated union を活用しているか
コードレビュー時の状態管理チェックポイント:
□ useEffect で状態を同期していないか → useMemo / 導出値に
□ props を useState にコピーしていないか → そのまま使う or key でリセット
□ グローバルストアが肥大化していないか → 分割
□ 同じデータが複数箇所で管理されていないか → Single Source of Truth
□ 不変性が守られているか → Immer or スプレッド
□ 適切なメモ化がされているか → ただし過剰なメモ化も避ける
14. 状態管理ライブラリの歴史と変遷
Reactの状態管理ライブラリの歴史:
2014: Flux(Facebookが提唱)
→ 単方向データフローの概念を広めた
→ 実装は複数(Fluxxor, Alt, Reflux等)
2015: Redux(Dan Abramov)
→ Fluxの実装を統一
→ 単一ストア、純粋なReducer、不変性
→ React エコシステムの事実上の標準に
→ ボイラープレートの多さが批判の対象に
2016-2018: MobX
→ Observable パターンで状態変更を自動追跡
→ ボイラープレートが少ない
→ 「magic」が多いという批判も
2019: Redux Toolkit
→ Redux のボイラープレートを大幅削減
→ createSlice, createAsyncThunk
→ 公式推奨のReduxの書き方に
2020: Recoil(Facebook実験的)
→ アトムベースの状態管理
→ React の concurrent features との相性を意識
→ 2025年時点でメンテナンス停滞
2020: React Query(TanStack Query)
→ サーバー状態の管理を革命的に簡素化
→ 「サーバー状態はクライアント状態ではない」という認識を広めた
2021: Zustand
→ シンプル、軽量、ボイラープレート最小
→ React の外からもアクセス可能
→ 急速にシェアを拡大
2021: Jotai
→ Recoilのコンセプトをよりシンプルに
→ アトムベース、TypeScript ファースト
→ pmndrs(Zustandと同じ開発者グループ)
2022-2024: サーバーコンポーネント時代
→ Next.js App Router / React Server Components
→ サーバーでデータを取得 → クライアント状態の必要性が減少
→ 「本当にクライアントで管理すべき状態」の見極めが重要に
2024-2026: 現在のトレンド
→ 軽量ライブラリ(Zustand, Jotai)が主流
→ TanStack Query がサーバー状態管理のデファクト
→ URL状態管理(nuqs等)への関心の高まり
→ React 19 の新しいフック(useActionState, useOptimistic, use)
→ signals への関心(Preact Signals, Angular Signals)
まとめ
| カテゴリ | 例 | 推奨ツール | 選定理由 |
|---|---|---|---|
| ローカル | モーダル開閉、入力値 | useState, useReducer | React組み込み、追加依存なし |
| グローバル | テーマ、認証 | Zustand, Context | 軽量、シンプル、型安全 |
| サーバー | API データ | TanStack Query | キャッシュ、再検証、リトライ自動 |
| URL | 検索、フィルタ | useSearchParams, nuqs | ブックマーク、共有、SEO |
| フォーム | バリデーション | React Hook Form + Zod | パフォーマンス、型安全 |
状態管理の黄金律
1. 「最も局所的な場所で、最も適切なツールで管理する」
2. 「状態は最小限に。計算可能な値は状態にしない」
3. 「サーバーデータはサーバー状態として管理する」
4. 「URLに反映すべきものはURL状態として管理する」
5. 「グローバル状態は最後の手段。まずローカル、次にコンポジション」
6. 「Single Source of Truth を守る」
7. 「不変性を守る。直接変更しない」
8. 「テスト可能な設計にする」
FAQ
Q1: Reduxは今でも必要か?
A: 2026年現在、新規プロジェクトでReduxを採用する必然性は低くなっている。ただし、以下の場合は検討に値する:
- 大規模エンタープライズアプリケーション: 複雑な状態遷移ルールがあり、Redux DevToolsの強力なデバッグ機能が必要な場合
- 既存のReduxエコシステム: チームがReduxに精通しており、Redux Toolkitのベストプラクティスが確立されている場合
- 厳密なアーキテクチャ: Action → Reducer → State の一方向フローを明示的に強制したい場合
一方、中小規模のプロジェクトでは、Zustand(軽量・シンプル)やJotai(アトムベース)を選ぶことで、Reduxのボイラープレートなしに同等の機能を実現できる。サーバーデータについては、TanStack Queryに任せることで、Reduxでの管理が不要になるケースが多い。
Q2: 状態管理ライブラリの選択基準は?
A: プロジェクトの特性に応じて以下の基準で選定する:
軽量・シンプルを重視(中規模アプリ):
- Zustand: ストアベース、React外アクセス可能、ミドルウェア充実
- バンドルサイズ: ~1.1KB、学習コスト: 低
細かい再レンダリング制御(複雑なUI):
- Jotai: アトムベース、派生状態が多い、動的なフィールド管理
- バンドルサイズ: ~3.8KB、学習コスト: 中
低頻度更新の共有状態(テーマ/認証):
- React Context: 追加依存なし、ただし頻繁な更新には不向き
大規模・厳密なアーキテクチャ:
- Redux Toolkit: 成熟したエコシステム、強力なDevTools
- バンドルサイズ: ~12.7KB、学習コスト: 高
実務では、**Zustand(グローバル状態)+ TanStack Query(サーバー状態)+ URL状態(nuqs)+ useState(ローカル状態)**の組み合わせが最も多く採用されている。
Q3: グローバル状態とサーバー状態をどう分離すべきか?
A: この分離が不適切だと、キャッシュ管理やデータ同期が複雑化する。以下の原則に従う:
サーバー状態として管理すべきデータ(TanStack Query / SWR):
- APIから取得したデータ(ユーザー一覧、商品データ、注文履歴)
- 他のクライアントが同時に変更する可能性があるデータ
- 定期的な再検証が必要なデータ → 理由: 自動キャッシュ、自動再検証、楽観的更新、リトライロジックが組み込み済み
グローバル状態として管理すべきデータ(Zustand / Jotai):
- クライアント側でのみ管理される状態(カート、UIテーマ、認証トークン)
- サーバーに永続化されないデータ
- React外からアクセスする必要があるデータ → 理由: アプリケーションのライフタイム全体で保持、localStorage永続化が容易
よくある間違い: サーバーから取得したデータをuseStateやZustandストアに保存し、手動でキャッシュ管理・再検証を実装する。これはアンチパターンであり、TanStack Queryに任せるべき。
次に読むべきガイド
参考文献
- Kent C. Dodds. "Application State Management with React." kentcdodds.com, 2020.
- TkDodo. "Practical React Query." tkdodo.eu, 2024.
- Zustand. "Documentation." github.com/pmndrs/zustand, 2024.
- Jotai. "Documentation." jotai.org, 2024.
- TanStack Query. "Documentation." tanstack.com/query, 2024.
- React. "Managing State." react.dev, 2024.
- Mark Erikson. "Blogged Answers: Why Redux Toolkit Uses Thunks for Async Logic." blog.isquaredsoftware.com, 2023.
- Daishi Kato. "When I Use Valtio and When I Use Jotai." blog.axlight.com, 2023.
- nuqs. "Documentation." nuqs.47ng.com, 2024.
- React. "React 19 Blog Post." react.dev, 2024.