1. ホーム
  2. React

【React】useReducer の使い方|複雑な状態管理を整理する(useStateとの使い分け)

Share

コンポーネントの状態が増えてくると、useState をいくつも並べて、あちこちで setXxx を呼ぶうちに「どこで何が更新されているのか」が追いづらくなってきます。そんなときに状態の更新ロジックを一か所にまとめられるのが useReducer フックです。この記事では、useReducer の基本的な形から、カウンターの例、フォームや ToDo のような複数フィールドを持つ状態への応用、そして useState との使い分けまでを、初心者〜中級者向けに具体的なコードで解説します。

useReducer の基本的な形

useReducer は次のように書きます。分割代入で受け取る state は現在の状態、dispatch は状態を更新するために「アクション」を送る関数です。第1引数の reducer は状態の更新ルールを書いた関数、第2引数の initialState は状態の初期値です。ここで出てくるアクション(action)とは「何をしたいか」を表すただのオブジェクトのことで、ディスパッチ(dispatch)は「そのアクションを reducer に届ける」操作だと考えてください。

counter.jsx
const [state, dispatch] = useReducer(reducer, initialState);

中心になるのが reducer 関数です。reducer は (state, action) => newState という形で、「今の状態」と「アクション」を受け取り、「次の状態」を返す純粋な関数です。ここでいう純粋な関数とは、同じ入力なら必ず同じ結果を返し、外側の値を書き換えたり副作用を起こしたりしない関数のことです。状態をどう変えるかのルールがすべてこの関数に集まるので、更新処理が散らからずに済みます。次の表で、登場する要素の役割を整理しておきます。

要素説明
state現在の状態。useState の値と同じく、読み取り専用として扱う
dispatchaction を送って状態更新をきっかけとする関数。dispatch(action) の形で呼ぶ
reducer(state, action) => newState。次の状態を計算して返す純粋な関数
action「何をしたいか」を表すオブジェクト。慣例で type プロパティを持たせる
initialState状態の初期値(第2引数)

カウンターで流れをつかむ

まずは一番シンプルなカウンターで、dispatch から reducer を経て状態が更新されるまでの流れを見てみます。ボタンを押すと dispatch({ type: 'increment' }) が呼ばれ、React がその action を reducer に渡し、返ってきた新しい状態で再レンダリングされます。

counter.jsx
import { useReducer } from 'react';

// 状態の初期値
const initialState = { count: 0 };

// (state, action) => newState の形
function reducer(state, action) {
  switch (action.type) {
    case 'increment':
      return { count: state.count + 1 };
    case 'decrement':
      return { count: state.count - 1 };
    case 'reset':
      return { count: 0 };
    default:
      // 想定外の action はそのまま今の状態を返す
      return state;
  }
}

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

  return (
    <div>
      <p>カウント: {state.count}</p>
      <button onClick={() => dispatch({ type: 'decrement' })}>-1</button>
      <button onClick={() => dispatch({ type: 'increment' })}>+1</button>
      <button onClick={() => dispatch({ type: 'reset' })}>リセット</button>
    </div>
  );
}

ポイントは、コンポーネント側には「どう更新するか」が一切書かれていないことです。ボタンは dispatch({ type: 'increment' }) のように「何をしたいか」を宣言するだけで、実際の計算はすべて reducer の中にまとまっています。switchdefault で今の状態をそのまま返しているのは、対応しない action が来ても状態を壊さないための安全策です。また reducer の中では state を直接書き換えず、必ず新しいオブジェクトを返す点にも注意してください。state.count++ のように元の状態を変更すると、React が変化を正しく検知できないことがあります。

action に payload を持たせる

action には type 以外の情報を一緒に載せられます。この追加データは慣例で payload(ペイロード=「積み荷」の意味)と呼ばれます。たとえば「指定した数だけ増やす」なら、増やす量を payload として渡します。

counter.jsx
function reducer(state, action) {
  switch (action.type) {
    case 'incrementBy':
      // payload で受け取った量だけ増やす
      return { count: state.count + action.payload };
    default:
      return state;
  }
}

// 呼び出し側: 5 増やしたい
dispatch({ type: 'incrementBy', payload: 5 });

payload という名前は決まりではなく、action.amount のように分かりやすい名前でも構いません。大切なのは、reducer が action の中身を見て次の状態を決めるという一貫した流れです。この「type で分岐し、必要なデータは action に載せる」パターンを覚えておくと、更新のバリエーションが増えても reducer の中だけで管理できます。

複数フィールドを持つ状態に広げる(ToDo リスト)

useReducer の効果がはっきり出るのは、状態が単純な数値ではなく、配列やオブジェクトのように複雑になったときです。ここでは ToDo リストを例にします。「追加」「完了の切り替え」「削除」といった複数の更新が絡みますが、すべて reducer の中に集約できます。

todo.jsx
import { useReducer, useState } from 'react';

const initialTodos = []; // { id, text, done } の配列

function todoReducer(todos, action) {
  switch (action.type) {
    case 'added':
      return [...todos, { id: action.id, text: action.text, done: false }];
    case 'toggled':
      return todos.map((todo) =>
        todo.id === action.id ? { ...todo, done: !todo.done } : todo
      );
    case 'deleted':
      return todos.filter((todo) => todo.id !== action.id);
    default:
      return todos;
  }
}

function TodoApp() {
  const [todos, dispatch] = useReducer(todoReducer, initialTodos);
  const [text, setText] = useState('');

  const handleAdd = () => {
    if (text.trim() === '') return;
    // id は簡易的に現在時刻を利用
    dispatch({ type: 'added', id: Date.now(), text });
    setText('');
  };

  return (
    <div>
      <input value={text} onChange={(e) => setText(e.target.value)} />
      <button onClick={handleAdd}>追加</button>
      <ul>
        {todos.map((todo) => (
          <li key={todo.id}>
            <span onClick={() => dispatch({ type: 'toggled', id: todo.id })}>
              {todo.done ? '✓ ' : ''}{todo.text}
            </span>
            <button onClick={() => dispatch({ type: 'deleted', id: todo.id })}>
              削除
            </button>
          </li>
        ))}
      </ul>
    </div>
  );
}

ToDo の一覧という「複雑な状態」の更新ロジックが todoReducer にまとまり、コンポーネントは dispatch で「追加した」「切り替えた」「削除した」という出来事を伝えるだけになっています。ここでも mapfilter、スプレッド構文 [...todos] を使って必ず新しい配列・オブジェクトを返し、元の状態を書き換えていない点に注目してください。なお、入力欄の文字のように単純な状態は useState のまま扱っており、すべてを reducer に押し込む必要はないことも分かります。

useState と useReducer の使い分け

useStateuseReducer はどちらも状態を扱うフックで、できることに本質的な差はありません。判断の目安は、状態や更新ロジックがどれくらい複雑かです。次のような場面では useReducer が向いています。

こんなとき向いているフック
ひとつの値やフラグを持つだけuseState
複数のフィールドが関連して変化するuseReducer
更新の種類が多く、ロジックが絡み合うuseReducer
次の状態が前の状態から計算されるuseReducer
更新処理をコンポーネントの外にまとめたいuseReducer

言い換えると、状態が単純なら useState、更新ロジックが複雑で「次の状態が前の状態に依存する」ような場面では useReducer、というのが基本の方針です。reducer は純粋な関数なので単体でテストしやすく、更新の全パターンが一か所に並ぶため見通しも良くなります。一方で、状態がフラグひとつ、といった単純なケースで useReducer を使うと、かえって記述量が増えて回りくどくなります。まずは useState で書き、状態や更新が育って手に負えなくなってきたら useReducer に移す、という順序で十分です。

初期化関数(第3引数)で初期状態を作る

useReducer には省略可能な第3引数として初期化関数を渡せます。useReducer(reducer, initialArg, init) と書くと、初期状態は init(initialArg) の戻り値になります。初期状態を計算で作りたいときや、リセット処理で初期状態を再利用したいときに便利です。

counter.jsx
// initialArg(初期値のもと)から状態を組み立てる
function init(initialCount) {
  return { count: initialCount };
}

function reducer(state, action) {
  switch (action.type) {
    case 'reset':
      // reset のときも同じ init を使い回せる
      return init(action.payload);
    case 'increment':
      return { count: state.count + 1 };
    default:
      return state;
  }
}

function Counter({ initialCount }) {
  // 第3引数 init に initialCount を渡して初期状態を作る
  const [state, dispatch] = useReducer(reducer, initialCount, init);

  return (
    <div>
      <p>カウント: {state.count}</p>
      <button onClick={() => dispatch({ type: 'increment' })}>+1</button>
      <button onClick={() => dispatch({ type: 'reset', payload: initialCount })}>
        リセット
      </button>
    </div>
  );
}

第3引数を使う利点は主に2つあります。ひとつは、初期状態を作る処理がコンポーネントの外の関数にまとまること。もうひとつは、init を reducer 側からも呼べるので、リセット時に初期状態を作り直す処理を共通化できることです。初期状態が単純なオブジェクトで済むうちは第3引数は不要で、初期化に計算が必要になってきたら検討する、という位置づけで問題ありません。

まとめ

useReducerconst [state, dispatch] = useReducer(reducer, initialState) の形で使い、状態の更新ロジックを (state, action) => newState という reducer 関数に集約するフックです。コンポーネントは dispatch({ type: '...' }) で「何をしたいか」を伝えるだけになり、更新の詳細は reducer にまとまります。カウンターのような単純な状態は useState で十分ですが、フォームや ToDo リストのように複数フィールドが絡み、更新の種類が増え、次の状態が前の状態に依存するようになったら useReducer の出番です。action には payload で追加データを載せられ、初期化が複雑なときは第3引数の初期化関数を使えます。まずは useState で書き始め、複雑になってきたら useReducer へ、という切り替えを覚えておきましょう。

参考ページ