0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Qiita Advent Calender 2025企画】React Hooks を Hackしよう!【Part3: useReducerをふかぼってみよう】

Last updated at Posted at 2025-12-04

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. 複数の関連状態を1つのオブジェクトで管理
  2. 更新ロジックを reducer 関数に集約
  3. アクションベースの明示的な状態遷移
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

ステップごとの解説:

  1. Hook ノードの作成 (mountWorkInProgressHook)

    • Fiber に紐づく Hook チェーンに新しいノードを追加
  2. 初期状態の計算

    • init 関数が渡されている場合は init(initialArg) を実行
    • そうでなければ initialArg をそのまま使用
  3. Hook に状態を保存

    • hook.memoizedState: 現在の状態
    • hook.baseState: 基準となる状態(更新の起点)
  4. UpdateQueue の作成

    • pending: 保留中の更新(循環リスト)
    • lastRenderedReducer: 最後に使用した reducer 関数
    • lastRenderedState: 最後にレンダリングした状態
  5. 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

ステップごとの解説:

  1. 優先度レーン(Lane)の取得

    • requestUpdateLane(fiber): 更新の優先度を決定
    • 例: ユーザー入力は高優先度、データフェッチは低優先度
  2. Update オブジェクトの作成

    • action: dispatch に渡されたアクション
    • lane: 更新の優先度
    • next: 循環リストの次のノード(後で設定される)
  3. レンダー中かチェック

    • レンダー中の更新は特別な処理が必要(無限ループ防止)
    • 通常は enqueueConcurrentHookUpdate でキューに追加
  4. 再レンダリングのスケジュール

    • 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 で行われます。この関数は以下を行います:

  1. 保留中の更新をベースキューにマージ

    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;
    }
    
  2. キューの各更新を処理

    • 各 Update を順番に処理
    • 優先度が低い更新はスキップ(後で再処理)
    • reducer 関数を呼び出して新しい状態を計算
  3. 新しい状態を 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 の使用を検討しましょう:

  1. 複数の関連する状態がある

    • 例: フォームの複数フィールド、カートの商品と合計金額
  2. 次の状態が現在の状態に依存する複雑なロジック

    • 例: ステートマシン、ゲームのスコア計算
  3. 状態遷移のパターンが明確

    • 例: ローディング → 成功/失敗、開始 → 処理中 → 完了
  4. Context と組み合わせたグローバル状態管理

    • Redux のようなパターンを軽量に実装したい場合
  5. テストしやすい状態管理が必要

    • Reducer は純粋関数なので単体テストが容易

6.2 useState との使い分けチートシート

条件 useState useReducer
状態が単純な値(boolean, number, string)
複数の関連する状態
複雑な更新ロジック
状態遷移が明確
Context と組み合わせる
テストのしやすさ重視
コード量を減らしたい
  1. useState は useReducer の特殊ケース

    • 内部的に同じメカニズムを使用している
    • useState は basicStateReducer を使った useReducer
  2. 更新はキューで管理される

    • 複数の dispatch は順番に処理される
    • 優先度(Lane)によってスケジューリングされる
  3. Fiber と Hook の連携

    • Hook は Fiber ノードに紐づく連結リスト
    • レンダーごとに Hook チェーンを辿って状態を復元
  4. パフォーマンス最適化

    • Eager evaluation: 可能な場合は即座に新しい状態を計算
    • Base queue: 優先度の低い更新は後回しにできる

useReducer は強力なツールですが、すべての状態管理に必要なわけではありません。シンプルな状態には useState を使い、複雑になってきたら useReducer への移行を検討するというアプローチが現実的です。状態管理の選択は、プロジェクトの規模や要件に応じて柔軟に行いましょう!

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?