1
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?

useReducer 完全ガイド — いつ使い、どう設計するか

結論

「複数の state が連動して変わる」「更新ロジックがコンポーネント外に切り出せる」場面では useReducer を使う。 それ以外は useState で十分。

判断はこの1行でほぼ足りる:

1回のイベントで setXxx を2回以上呼んでいたら、useReducer の出番。


1. useState との違い

useState は「次の値」を渡す。useReducer は「何が起きたか」を渡す。

// useState: 呼び出し側が「次の状態」を計算する責任を持つ
setCount(count + 1);
setError(null);
setIsDirty(true);

// useReducer: 呼び出し側は「起きた事実」だけ伝える
dispatch({ type: 'increment' });

この違いが効いてくるのは、状態が増えたときuseState だと更新ロジックがコンポーネント内に散らばるが、useReducer なら reducer 関数1つに集約される。

基本形

import { useReducer } from 'react';

const initialState = { count: 0, step: 1 };

function reducer(state, action) {
  switch (action.type) {
    case 'increment':
      return { ...state, count: state.count + state.step };
    case 'decrement':
      return { ...state, count: state.count - state.step };
    case 'setStep':
      return { ...state, step: action.payload };
    case 'reset':
      return initialState;
    default:
      throw new Error(`Unknown action: ${action.type}`);
  }
}

function Counter() {
  const [state, dispatch] = useReducer(reducer, initialState);

  return (
    <div>
      <p>{state.count}</p>
      <button onClick={() => dispatch({ type: 'increment' })}>+</button>
      <button onClick={() => dispatch({ type: 'decrement' })}>-</button>
      <input
        type="number"
        value={state.step}
        onChange={(e) => dispatch({ type: 'setStep', payload: Number(e.target.value) })}
      />
    </div>
  );
}

Laravel をやっている人なら、reducer は Eloquent のモデルメソッドに近い感覚で捉えるとしっくりくる。「状態をどう変えるか」の知識をモデル側に持たせて、呼び出し側は意図だけを伝える構造。


2. reducer は純粋関数でなければならない

これは仕様上の制約というより、React が本当に2回呼ぶので実害が出る。開発モードの StrictMode では reducer が意図的に2回実行される(純粋性のバグ検出のため)。

// ❌ ダメな例
function reducer(state, action) {
  switch (action.type) {
    case 'addItem':
      state.items.push(action.payload); // ミューテーション
      localStorage.setItem('items', JSON.stringify(state.items)); // 副作用
      return state; // 同じ参照 → 再レンダリングされない
  }
}

// ✅ 正しい例
function reducer(state, action) {
  switch (action.type) {
    case 'addItem':
      return { ...state, items: [...state.items, action.payload] };
  }
}

localStorage への保存などの副作用は useEffect 側に出す:

useEffect(() => {
  localStorage.setItem('items', JSON.stringify(state.items));
}, [state.items]);

3. 実践例①: フォームの状態管理

バリデーション付きフォームは、useState を4つ5つ並べると一気に破綻する典型例。

const initialState = {
  values: { email: '', password: '' },
  errors: {},
  isSubmitting: false,
  submitError: null,
};

function formReducer(state, action) {
  switch (action.type) {
    case 'change':
      return {
        ...state,
        values: { ...state.values, [action.field]: action.value },
        // 入力したフィールドのエラーだけ消す
        errors: { ...state.errors, [action.field]: undefined },
      };

    case 'submitStart':
      return { ...state, isSubmitting: true, submitError: null };

    case 'submitSuccess':
      return initialState;

    case 'submitFailure':
      return {
        ...state,
        isSubmitting: false,
        errors: action.errors ?? {},
        submitError: action.message ?? null,
      };

    default:
      return state;
  }
}

function LoginForm() {
  const [state, dispatch] = useReducer(formReducer, initialState);

  const handleSubmit = async (e) => {
    e.preventDefault();
    dispatch({ type: 'submitStart' });

    try {
      const res = await fetch('/api/login', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(state.values),
      });

      if (res.status === 422) {
        const body = await res.json();
        // Laravel のバリデーションエラー形式をそのまま流し込める
        dispatch({ type: 'submitFailure', errors: body.errors });
        return;
      }
      if (!res.ok) throw new Error('Request failed');

      dispatch({ type: 'submitSuccess' });
    } catch (err) {
      dispatch({ type: 'submitFailure', message: err.message });
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        value={state.values.email}
        onChange={(e) => dispatch({ type: 'change', field: 'email', value: e.target.value })}
      />
      {state.errors.email && <span>{state.errors.email[0]}</span>}

      <button disabled={state.isSubmitting}>
        {state.isSubmitting ? '送信中...' : 'ログイン'}
      </button>
    </form>
  );
}

ポイントは submitStartisSubmitting: truesubmitError: null を同時に変えているところ。useState だと2回 setter を呼ぶことになり、「エラーが残ったままローディングが始まる」ような中間状態のバグが入り込む余地ができる。


4. 実践例②: Undo / Redo

useReducer が圧倒的に強いのがこれ。エディタ系アプリなら定番。

const initialState = {
  past: [],
  present: '',
  future: [],
};

function historyReducer(state, action) {
  const { past, present, future } = state;

  switch (action.type) {
    case 'edit':
      if (action.value === present) return state;
      return {
        past: [...past, present].slice(-100), // 履歴は100件まで
        present: action.value,
        future: [],
      };

    case 'undo': {
      if (past.length === 0) return state;
      const previous = past[past.length - 1];
      return {
        past: past.slice(0, -1),
        present: previous,
        future: [present, ...future],
      };
    }

    case 'redo': {
      if (future.length === 0) return state;
      const [next, ...rest] = future;
      return {
        past: [...past, present],
        present: next,
        future: rest,
      };
    }

    default:
      return state;
  }
}

これを useState でやろうとすると、past / present / future の3つを常に整合させながら更新する必要があり、ロジックがイベントハンドラ側に漏れ出して手に負えなくなる。


5. TypeScript での型付け

判別可能なユニオン型 (discriminated union) を使うのが定石。これで payload の型まで action ごとに正確に絞り込める。

type Todo = { id: string; text: string; done: boolean };

type State = {
  todos: Todo[];
  filter: 'all' | 'active' | 'completed';
};

type Action =
  | { type: 'add'; text: string }
  | { type: 'toggle'; id: string }
  | { type: 'remove'; id: string }
  | { type: 'setFilter'; filter: State['filter'] };

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'add':
      // ここでは action.text だけが存在することが保証される
      return {
        ...state,
        todos: [...state.todos, { id: crypto.randomUUID(), text: action.text, done: false }],
      };

    case 'toggle':
      return {
        ...state,
        todos: state.todos.map((t) => (t.id === action.id ? { ...t, done: !t.done } : t)),
      };

    case 'remove':
      return { ...state, todos: state.todos.filter((t) => t.id !== action.id) };

    case 'setFilter':
      return { ...state, filter: action.filter };

    default: {
      // 網羅性チェック: action を追加して case を書き忘れるとコンパイルエラーになる
      const _exhaustive: never = action;
      return state;
    }
  }
}

この never による網羅性チェックが useReducer + TS の最大のうまみ。action を追加した瞬間に、対応漏れがビルド時に落ちる。


6. 第3引数: 遅延初期化

初期 state の計算がコスト高い場合、第3引数の init 関数を使う。

function init(initialText) {
  return {
    present: initialText,
    past: [],
    future: [],
  };
}

// init(props.text) は初回レンダリング時のみ実行される
const [state, dispatch] = useReducer(historyReducer, props.text, init);

第2引数に直接オブジェクトを書くと毎レンダリングでオブジェクトが生成される(使われはしないが無駄)。重い処理なら init に逃がす。reset アクションで初期状態に戻したいときにも再利用できて便利。


7. useContext との組み合わせ

Props drilling を避けたいとき、useReducer + useContext は Redux 的な構成の軽量版になる。

const StateContext = createContext(null);
const DispatchContext = createContext(null);

export function TodoProvider({ children }) {
  const [state, dispatch] = useReducer(reducer, initialState);

  return (
    <StateContext.Provider value={state}>
      {/* dispatch は再生成されないので、Provider を分けると再レンダリングを抑えられる */}
      <DispatchContext.Provider value={dispatch}>
        {children}
      </DispatchContext.Provider>
    </StateContext.Provider>
  );
}

export const useTodos = () => useContext(StateContext);
export const useTodoDispatch = () => useContext(DispatchContext);

state と dispatch の Context を分けるのが重要。 dispatch は React が同一参照を保証するので、dispatch だけを使うコンポーネントは state 変更で再レンダリングされずに済む。

// state の変更では再レンダリングされない
function AddButton() {
  const dispatch = useTodoDispatch();
  return <button onClick={() => dispatch({ type: 'add', text: '新規' })}>追加</button>;
}

8. よくあるアンチパターン

❌ setter の別名として使う

// これは useState でいい
dispatch({ type: 'setCount', value: 5 });
dispatch({ type: 'setName', value: 'foo' });

action が全部 setXxx になっているなら、抽象化に失敗している。action 名はドメインの出来事で命名する: submitted, itemAdded, filterChanged

❌ reducer に非同期処理を入れる

reducer は同期・純粋。API 呼び出しはイベントハンドラか useEffect に置き、結果を dispatch する。

❌ 巨大な単一 reducer

無関係な state を1つの reducer に詰め込まない。「一緒に変わるもの」ごとに分割する。


9. 選択の指針

状況 推奨
独立した boolean / string 1つ useState
1イベントで2つ以上の state が変わる useReducer
次の state が前の state に強く依存 useReducer
Undo/Redo、履歴管理 useReducer
更新ロジックをテストしたい useReducer(reducer は純関数なので単体テストが楽)
サーバー状態のキャッシュ TanStack Query など専用ライブラリ
フォーム送信 (React 19+) useActionState も検討

reducer のテストがラクな例

test('undo は直前の状態に戻る', () => {
  let state = { past: ['a'], present: 'b', future: [] };
  state = historyReducer(state, { type: 'undo' });

  expect(state.present).toBe('a');
  expect(state.future).toEqual(['b']);
});

React のレンダリングを一切起動せずにロジックを検証できる。これは useState では得られない利点。


まとめ

  • useReducer は「状態遷移をコンポーネント外に切り出す」ための道具
  • 純粋関数を守る。副作用は useEffect
  • TypeScript では判別可能なユニオン + never の網羅性チェックがセット
  • Context と組むなら state / dispatch を分離
  • 迷ったら useState から始めて、setter を2回呼び始めた時点で移行する
1
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
1
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?