React で複雑な状態管理を行う際、useState では物足りなくなることがあります。複数の関連する状態、複雑な更新ロジック、そんな時に登場するのが useReducer です。本記事では、useReducer の基礎から実装の深掘り、実践的なユースケースまでをカバーし、「いつ useState から useReducer に切り替えるべきか?」を理解できる記事にしていきます!
1. なぜ useReducer が必要か
1.1 useState の限界
useState は単純な状態管理には最適ですが、以下のような状況では扱いにくくなります:
// 複数の関連する状態を管理する場合
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [age, setAge] = useState(0);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState(null);
// 更新ロジックが複雑になりがち
const handleSubmit = () => {
setIsSubmitting(true);
setError(null);
// ... 複雑な処理
setIsSubmitting(false);
};
関連する状態が増えると、どの setState をいつ呼ぶべきか追跡が困難になります。
1.2 useReducer の役割は「状態更新ロジックの一元管理」
useReducer は Redux と同じく Reducer パターンを採用し、次の利点を提供します:
- 複数の関連状態を1つのオブジェクトで管理
- 更新ロジックを reducer 関数に集約
- アクションベースの明示的な状態遷移
type State = {
name: string;
email: string;
age: number;
isSubmitting: boolean;
error: string | null;
};
type Action =
| { type: 'SET_FIELD'; field: keyof State; value: any }
| { type: 'SUBMIT_START' }
| { type: 'SUBMIT_SUCCESS' }
| { type: 'SUBMIT_ERROR'; error: string };
const reducer = (state: State, action: Action): State => {
switch (action.type) {
case 'SET_FIELD':
return { ...state, [action.field]: action.value };
case 'SUBMIT_START':
return { ...state, isSubmitting: true, error: null };
case 'SUBMIT_SUCCESS':
return { ...state, isSubmitting: false };
case 'SUBMIT_ERROR':
return { ...state, isSubmitting: false, error: action.error };
default:
return state;
}
};
const [state, dispatch] = useReducer(reducer, initialState);
// 使用例
dispatch({ type: 'SUBMIT_START' });
1.3 useState との使い分け
useState を使うべき場合:
- 状態が独立している
- 更新ロジックが単純
- 真偽値やカウンターなど単一の値
useReducer を使うべき場合:
- 複数の関連する状態がある
- 次の状態が現在の状態に依存する複雑なロジック
- 状態遷移のパターンが明確
- テストしやすい状態管理が必要
2. useReducerの内部構造を徹底解剖 — facebook/react ソースリーディング
useReducer の実装を理解することで、なぜこのパターンが効果的なのかが見えてきます。実は、useState は内部的に useReducer の特殊ケースとして実装されています。
2.0 全体像: useReducer が動く仕組みの5つのステージ
📱 コード: useReducer(reducer, initialState)
↓
🎭 Dispatcher (交通整理役): マウント時とアップデート時で異なる処理
↓
📦 Hook ノード (状態の保管庫): Fiber に紐づく Hook チェーン
↓
📮 UpdateQueue (更新リクエストの待ち行列): action を保持
↓
⚙️ Scheduler (再レンダリングの実行役): reducer で新しい state を計算
2.1 エントリーポイント: packages/react/src/ReactHooks.js
まず、 useReducer がどこにあるのか見てみましょう。
// packages/react/src/ReactHooks.js (73-81行目)
export function useReducer<S, I, A>(
reducer: (S, A) => S,
initialArg: I,
init?: I => S,
): [S, Dispatch<A>] {
const dispatcher = resolveDispatcher();
return dispatcher.useReducer(reducer, initialArg, init);
}
引用元: facebook/react - packages/react/src/ReactHooks.js#L73-L81
useState と同様、実際の処理は Dispatcher に委譲されます。Dispatcher はコンポーネントのライフサイクル(マウント時 / アップデート時)に応じて異なる関数を呼び出します。
2.2 マウント時の処理: mountReducer
初回レンダー時に呼ばれる mountReducer を見てみましょう。
// packages/react-reconciler/src/ReactFiberHooks.js (1257-1290行目)
function mountReducer<S, I, A>(
reducer: (S, A) => S,
initialArg: I,
init?: I => S,
): [S, Dispatch<A>] {
const hook = mountWorkInProgressHook();
let initialState;
if (init !== undefined) {
initialState = init(initialArg);
if (shouldDoubleInvokeUserFnsInHooksDEV) {
setIsStrictModeForDevtools(true);
try {
init(initialArg);
} finally {
setIsStrictModeForDevtools(false);
}
}
} else {
initialState = ((initialArg: any): S);
}
hook.memoizedState = hook.baseState = initialState;
const queue: UpdateQueue<S, A> = {
pending: null,
lanes: NoLanes,
dispatch: null,
lastRenderedReducer: reducer,
lastRenderedState: (initialState: any),
};
hook.queue = queue;
const dispatch: Dispatch<A> = (queue.dispatch = (dispatchReducerAction.bind(
null,
currentlyRenderingFiber,
queue,
): any));
return [hook.memoizedState, dispatch];
}
引用元: facebook/react - packages/react-reconciler/src/ReactFiberHooks.js#L1257-L1290
ステップごとの解説:
-
Hook ノードの作成 (
mountWorkInProgressHook)- Fiber に紐づく Hook チェーンに新しいノードを追加
-
初期状態の計算
-
init関数が渡されている場合はinit(initialArg)を実行 - そうでなければ
initialArgをそのまま使用
-
-
Hook に状態を保存
-
hook.memoizedState: 現在の状態 -
hook.baseState: 基準となる状態(更新の起点)
-
-
UpdateQueue の作成
-
pending: 保留中の更新(循環リスト) -
lastRenderedReducer: 最後に使用した reducer 関数 -
lastRenderedState: 最後にレンダリングした状態
-
-
dispatch 関数の作成
-
dispatchReducerActionを現在の Fiber と queue に bind - これが
dispatchとして返される関数
-
2.3 アクションの発火: dispatchReducerAction
ユーザーが dispatch(action) を呼び出すと、内部では dispatchReducerAction が実行されます。
// packages/react-reconciler/src/ReactFiberHooks.js (3556-3594行目)
function dispatchReducerAction<S, A>(
fiber: Fiber,
queue: UpdateQueue<S, A>,
action: A,
): void {
const lane = requestUpdateLane(fiber);
const update: Update<S, A> = {
lane,
revertLane: NoLane,
gesture: null,
action,
hasEagerState: false,
eagerState: null,
next: (null: any),
};
if (isRenderPhaseUpdate(fiber)) {
enqueueRenderPhaseUpdate(queue, update);
} else {
const root = enqueueConcurrentHookUpdate(fiber, queue, update, lane);
if (root !== null) {
startUpdateTimerByLane(lane, 'dispatch()', fiber);
scheduleUpdateOnFiber(root, fiber, lane);
entangleTransitionUpdate(root, queue, lane);
}
}
markUpdateInDevTools(fiber, lane, action);
}
引用元: facebook/react - packages/react-reconciler/src/ReactFiberHooks.js#L3556-L3594
ステップごとの解説:
-
優先度レーン(Lane)の取得
-
requestUpdateLane(fiber): 更新の優先度を決定 - 例: ユーザー入力は高優先度、データフェッチは低優先度
-
-
Update オブジェクトの作成
-
action: dispatch に渡されたアクション -
lane: 更新の優先度 -
next: 循環リストの次のノード(後で設定される)
-
-
レンダー中かチェック
- レンダー中の更新は特別な処理が必要(無限ループ防止)
- 通常は
enqueueConcurrentHookUpdateでキューに追加
-
再レンダリングのスケジュール
-
scheduleUpdateOnFiber: Fiber ツリーの再レンダリングを予約 - これにより React の調整プロセスが開始される
-
2.4 更新時の処理: updateReducer
2回目以降のレンダーでは updateReducer が呼ばれます。
// packages/react-reconciler/src/ReactFiberHooks.js (1293-1302行目)
function updateReducer<S, I, A>(
reducer: (S, A) => S,
initialArg: I,
init?: I => S,
): [S, Dispatch<A>] {
const hook = updateWorkInProgressHook();
return updateReducerImpl(hook, ((currentHook: any): Hook), reducer);
}
引用元: facebook/react - packages/react-reconciler/src/ReactFiberHooks.js#L1293-L1302
実際の処理は updateReducerImpl で行われます。この関数は以下を行います:
-
保留中の更新をベースキューにマージ
const pendingQueue = queue.pending; if (pendingQueue !== null) { // ベースキューとペンディングキューを結合 if (baseQueue !== null) { const baseFirst = baseQueue.next; const pendingFirst = pendingQueue.next; baseQueue.next = pendingFirst; pendingQueue.next = baseFirst; } current.baseQueue = baseQueue = pendingQueue; queue.pending = null; } -
キューの各更新を処理
- 各 Update を順番に処理
- 優先度が低い更新はスキップ(後で再処理)
- reducer 関数を呼び出して新しい状態を計算
-
新しい状態を Hook に保存
hook.memoizedState = newState; hook.baseState = newBaseState; hook.baseQueue = newBaseQueueLast;
引用元: facebook/react - packages/react-reconciler/src/ReactFiberHooks.js#L1304-L1550
2.5 useState との関係
実は、useState は useReducer の特殊ケースとして実装されています!
useState が内部で使用する reducer は以下のようなシンプルなものです:
// 概念的なコード(実際の実装は最適化されています)
function basicStateReducer<S>(state: S, action: S | ((S) => S)): S {
return typeof action === 'function' ? action(state) : action;
}
つまり:
-
setState(newValue)→ reducer が単にnewValueを返す -
setState(prev => prev + 1)→ reducer が関数を実行して新しい値を返す
これにより、useState と useReducer は内部的に同じメカニズムを共有し、コードの重複を避けています。
3. 基本的な使い方とパターン
3.1 最もシンプルな例: カウンター
まずは useState との比較から始めましょう。
useState 版:
const Counter = () => {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>+1</button>
<button onClick={() => setCount(count - 1)}>-1</button>
<button onClick={() => setCount(0)}>Reset</button>
</div>
);
};
useReducer 版:
type State = { count: number };
type Action =
| { type: 'INCREMENT' }
| { type: 'DECREMENT' }
| { type: 'RESET' };
const reducer = (state: State, action: Action): State => {
switch (action.type) {
case 'INCREMENT':
return { count: state.count + 1 };
case 'DECREMENT':
return { count: state.count - 1 };
case 'RESET':
return { count: 0 };
default:
return state;
}
};
const Counter = () => {
const [state, dispatch] = useReducer(reducer, { count: 0 });
return (
<div>
<p>Count: {state.count}</p>
<button onClick={() => dispatch({ type: 'INCREMENT' })}>+1</button>
<button onClick={() => dispatch({ type: 'DECREMENT' })}>-1</button>
<button onClick={() => dispatch({ type: 'RESET' })}>Reset</button>
</div>
);
};
この例では useReducer は冗長に見えますが、アクションが増えると価値が出てきます。
3.2 初期化関数の活用
useReducer の第3引数に初期化関数を渡すことで、初期状態を遅延計算できます。
// 第2引数はそのまま初期状態
const [state, dispatch] = useReducer(reducer, { count: 0 });
// 第3引数に init 関数を渡すパターン
const init = (initialCount: number): State => {
// localStorage から復元するなど、重い処理も可能
const saved = localStorage.getItem('count');
return { count: saved ? parseInt(saved, 10) : initialCount };
};
const [state, dispatch] = useReducer(reducer, 10, init);
// init(10) が初期状態として使われる
これは useState の関数形式と同じく、初期化のコストを削減します:
// useState の場合
const [state, setState] = useState(() => expensiveComputation());
// useReducer の場合
const [state, dispatch] = useReducer(reducer, initialArg, expensiveInit);
4. 実践的なユースケース
useReducer が真価を発揮する代表的なパターンを2つ紹介します。
4.1 フォーム管理
複数の入力フィールドを持つフォームは、useReducer の最も典型的なユースケースです。
type FormState = {
name: string;
email: string;
isSubmitting: boolean;
error: string | null;
};
type FormAction =
| { type: 'SET_FIELD'; field: 'name' | 'email'; value: string }
| { type: 'SUBMIT_START' }
| { type: 'SUBMIT_SUCCESS' }
| { type: 'SUBMIT_ERROR'; error: string };
const formReducer = (state: FormState, action: FormAction): FormState => {
switch (action.type) {
case 'SET_FIELD':
return { ...state, [action.field]: action.value };
case 'SUBMIT_START':
return { ...state, isSubmitting: true, error: null };
case 'SUBMIT_SUCCESS':
return { name: '', email: '', isSubmitting: false, error: null };
case 'SUBMIT_ERROR':
return { ...state, isSubmitting: false, error: action.error };
default:
return state;
}
};
const UserForm = () => {
const [state, dispatch] = useReducer(formReducer, {
name: '', email: '', isSubmitting: false, error: null
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
dispatch({ type: 'SUBMIT_START' });
try {
await submitForm(state);
dispatch({ type: 'SUBMIT_SUCCESS' });
} catch (error) {
dispatch({ type: 'SUBMIT_ERROR', error: 'エラーが発生しました' });
}
};
return (
<form onSubmit={handleSubmit}>
<input
value={state.name}
onChange={(e) => dispatch({ type: 'SET_FIELD', field: 'name', value: e.target.value })}
/>
<input
value={state.email}
onChange={(e) => dispatch({ type: 'SET_FIELD', field: 'email', value: e.target.value })}
/>
<button disabled={state.isSubmitting}>
{state.isSubmitting ? '送信中...' : '送信'}
</button>
</form>
);
};
このパターンの利点:
- すべての状態が1箇所で管理される
- 状態遷移が明確(SUBMIT_START → SUBMIT_SUCCESS or SUBMIT_ERROR)
- テストが容易(reducer は純粋関数)
4.2 Context と組み合わせたグローバル状態管理
useReducer と Context API を組み合わせることで、Redux のような軽量なグローバル状態管理が実現できます。
type AppState = { theme: 'light' | 'dark'; user: { name: string } | null };
type AppAction =
| { type: 'SET_THEME'; theme: 'light' | 'dark' }
| { type: 'LOGIN'; user: { name: string } }
| { type: 'LOGOUT' };
const appReducer = (state: AppState, action: AppAction): AppState => {
switch (action.type) {
case 'SET_THEME':
return { ...state, theme: action.theme };
case 'LOGIN':
return { ...state, user: action.user };
case 'LOGOUT':
return { ...state, user: null };
default:
return state;
}
};
const AppContext = createContext<{
state: AppState;
dispatch: React.Dispatch<AppAction>;
} | undefined>(undefined);
export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [state, dispatch] = useReducer(appReducer, { theme: 'light', user: null });
return (
<AppContext.Provider value={{ state, dispatch }}>
{children}
</AppContext.Provider>
);
};
// 使用例
const ThemeSwitcher = () => {
const { state, dispatch } = useContext(AppContext)!;
return (
<button onClick={() => dispatch({
type: 'SET_THEME',
theme: state.theme === 'light' ? 'dark' : 'light'
})}>
{state.theme === 'light' ? '🌙' : '☀️'}
</button>
);
};
5. ベストプラクティスとアンチパターン
この章では、useReducer を効果的に使うための重要なベストプラクティスを解説します。
5.1 ✅ DO: Reducer を純粋関数に保つ
公式ドキュメントより: Reducer は純粋でなければならず、引数として state とアクションを取り、次の state を返します。state 内のオブジェクトや配列を変更しないでください。
純粋関数の3つのルール:
- 引数以外の外部状態を読み取らない
- 引数を直接変更しない(イミュータブル)
- 副作用(API呼び出し、DOM操作など)を起こさない
// ✅ GOOD: 純粋関数
const pureReducer = (state: State, action: Action): State => {
switch (action.type) {
case 'ADD_ITEM':
return { ...state, items: [...state.items, action.item] };
default:
return state;
}
};
// ❌ BAD: state を直接変更(ミュータブル)
const impureReducer = (state: State, action: Action): State => {
switch (action.type) {
case 'ADD_ITEM':
state.items.push(action.item); // 元の配列を変更!
return state; // 同じオブジェクトを返している
default:
return state;
}
};
注意: state を直接変更すると、React は Object.is 比較で変更を検知できず、再レンダーがスキップされます。
5.2 ✅ DO: TypeScript の Tagged Union を活用
TypeScript の Tagged Union を使うことで、型安全性が向上します。
// ✅ GOOD: Tagged Union
type Action =
| { type: 'SET_NAME'; name: string }
| { type: 'SET_AGE'; age: number }
| { type: 'RESET' };
const reducer = (state: State, action: Action): State => {
switch (action.type) {
case 'SET_NAME':
return { ...state, name: action.name }; // action.name は string 型
case 'SET_AGE':
return { ...state, age: action.age }; // action.age は number 型
case 'RESET':
return initialState;
default:
const _exhaustiveCheck: never = action; // 網羅性チェック
return state;
}
};
5.3 ❌ DONT: Reducer の中で非同期処理
Reducer は同期的な純粋関数でなければなりません。非同期処理は useEffect やイベントハンドラで行います。
// ❌ BAD: reducer 内で非同期処理
const badReducer = (state: State, action: Action): State => {
switch (action.type) {
case 'SAVE':
fetch('/api/save', { method: 'POST' }); // 副作用!
return state;
}
};
// ✅ GOOD: 非同期処理は外で
const GoodComponent = () => {
const [state, dispatch] = useReducer(reducer, initialState);
const handleSave = async () => {
dispatch({ type: 'SAVE_START' });
try {
await fetch('/api/save', { method: 'POST' });
dispatch({ type: 'SAVE_SUCCESS' });
} catch (error) {
dispatch({ type: 'SAVE_ERROR', error: error.message });
}
};
};
5.4 ❌ DONT: 過度に細かい状態分割
関連する状態は1つの reducer で管理しましょう。
// ❌ BAD: 関連する状態を分割しすぎ
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState(null);
// ✅ GOOD: 関連する状態は1つの reducer で
const [state, dispatch] = useReducer(formReducer, {
name: '', email: '', isSubmitting: false, error: null
});
6. まとめ
6.1 useReducer を使うべきとき
以下の条件のいずれかに当てはまる場合、useReducer の使用を検討しましょう:
-
複数の関連する状態がある
- 例: フォームの複数フィールド、カートの商品と合計金額
-
次の状態が現在の状態に依存する複雑なロジック
- 例: ステートマシン、ゲームのスコア計算
-
状態遷移のパターンが明確
- 例: ローディング → 成功/失敗、開始 → 処理中 → 完了
-
Context と組み合わせたグローバル状態管理
- Redux のようなパターンを軽量に実装したい場合
-
テストしやすい状態管理が必要
- Reducer は純粋関数なので単体テストが容易
6.2 useState との使い分けチートシート
| 条件 | useState | useReducer |
|---|---|---|
| 状態が単純な値(boolean, number, string) | ✅ | ❌ |
| 複数の関連する状態 | ❌ | ✅ |
| 複雑な更新ロジック | ❌ | ✅ |
| 状態遷移が明確 | ❌ | ✅ |
| Context と組み合わせる | △ | ✅ |
| テストのしやすさ重視 | △ | ✅ |
| コード量を減らしたい | ✅ | ❌ |
-
useState は useReducer の特殊ケース
- 内部的に同じメカニズムを使用している
- useState は basicStateReducer を使った useReducer
-
更新はキューで管理される
- 複数の dispatch は順番に処理される
- 優先度(Lane)によってスケジューリングされる
-
Fiber と Hook の連携
- Hook は Fiber ノードに紐づく連結リスト
- レンダーごとに Hook チェーンを辿って状態を復元
-
パフォーマンス最適化
- Eager evaluation: 可能な場合は即座に新しい状態を計算
- Base queue: 優先度の低い更新は後回しにできる
- 公式ドキュメント: useReducer – React
useReducer は強力なツールですが、すべての状態管理に必要なわけではありません。シンプルな状態には useState を使い、複雑になってきたら useReducer への移行を検討するというアプローチが現実的です。状態管理の選択は、プロジェクトの規模や要件に応じて柔軟に行いましょう!