第6回

useReducerでの状態管理:複雑なstateをスッキリ整理する

ReactのuseReducerを解説します。action・reducer・dispatchの概念、TypeScriptでの型安全な実装、useStateとの使い分け基準を、ToDoアプリを例に実践的に学べます。

·14分で読める
たける
たける キャラクター選択ページで `selectedId`・`isLoading`・`error`・`searchQuery` って4つのstateができて、更新が絡み合ってどこで何が起きてるかわからなくなってきました……。
りこ
りこ それがuseReducerに切り替えるサイン。stateの数より「更新ロジックが複雑かどうか」「複数のstateが連動して変わるか」で判断する。

useReducerとは

useReducer は複数のstateが連動して変化したり、更新ロジックが複雑なときに useState の代わりに使うHookです。

const [state, dispatch] = useReducer(reducer, initialState);
  • state — 現在の状態
  • dispatch — アクションを送る関数
  • reducer — 「どのアクションが来たらどう状態を変えるか」を定義した純粋関数
  • initialState — 初期状態

reducerとactionの概念

現在の状態 + アクション → 新しい状態

(state, action) => newState
type CounterState = { count: number };
type CounterAction =
  | { type: 'increment' }
  | { type: 'decrement' }
  | { type: 'reset' }
  | { type: 'setCount'; payload: number };

function counterReducer(state: CounterState, action: CounterAction): CounterState {
  switch (action.type) {
    case 'increment':
      return { count: state.count + 1 };
    case 'decrement':
      return { count: state.count - 1 };
    case 'reset':
      return { count: 0 };
    case 'setCount':
      return { count: action.payload };
    default:
      return state;
  }
}

function Counter() {
  const [state, dispatch] = useReducer(counterReducer, { count: 0 });

  return (
    <div>
      <p>{state.count}</p>
      <button onClick={() => dispatch({ type: 'increment' })}>+1</button>
      <button onClick={() => dispatch({ type: 'decrement' })}>-1</button>
      <button onClick={() => dispatch({ type: 'reset' })}>リセット</button>
      <button onClick={() => dispatch({ type: 'setCount', payload: 10 })}>10にする</button>
    </div>
  );
}

実践:ToDoアプリ

複数の操作(追加・完了・削除・フィルター)が絡むToDoアプリで useReducer の真価が発揮されます。

type Todo = {
  id: number;
  text: string;
  completed: boolean;
};

type Filter = 'all' | 'active' | 'completed';

type TodoState = {
  todos: Todo[];
  filter: Filter;
  nextId: number;
};

type TodoAction =
  | { type: 'add'; text: string }
  | { type: 'toggle'; id: number }
  | { type: 'delete'; id: number }
  | { type: 'setFilter'; filter: Filter }
  | { type: 'clearCompleted' };

const initialState: TodoState = {
  todos: [],
  filter: 'all',
  nextId: 1,
};

function todoReducer(state: TodoState, action: TodoAction): TodoState {
  switch (action.type) {
    case 'add':
      return {
        ...state,
        todos: [...state.todos, { id: state.nextId, text: action.text, completed: false }],
        nextId: state.nextId + 1,
      };
    case 'toggle':
      return {
        ...state,
        todos: state.todos.map((todo) =>
          todo.id === action.id ? { ...todo, completed: !todo.completed } : todo
        ),
      };
    case 'delete':
      return {
        ...state,
        todos: state.todos.filter((todo) => todo.id !== action.id),
      };
    case 'setFilter':
      return { ...state, filter: action.filter };
    case 'clearCompleted':
      return { ...state, todos: state.todos.filter((todo) => !todo.completed) };
    default:
      return state;
  }
}

function TodoApp() {
  const [state, dispatch] = useReducer(todoReducer, initialState);
  const [input, setInput] = useState('');

  const filteredTodos = state.todos.filter((todo) => {
    if (state.filter === 'active') return !todo.completed;
    if (state.filter === 'completed') return todo.completed;
    return true;
  });

  const handleAdd = () => {
    if (!input.trim()) return;
    dispatch({ type: 'add', text: input });
    setInput('');
  };

  return (
    <div>
      <div>
        <input value={input} onChange={(e) => setInput(e.target.value)} />
        <button onClick={handleAdd}>追加</button>
      </div>

      <div>
        {(['all', 'active', 'completed'] as Filter[]).map((f) => (
          <button
            key={f}
            onClick={() => dispatch({ type: 'setFilter', filter: f })}
            style={{ fontWeight: state.filter === f ? 'bold' : 'normal' }}
          >
            {f}
          </button>
        ))}
      </div>

      <ul>
        {filteredTodos.map((todo) => (
          <li key={todo.id}>
            <input
              type="checkbox"
              checked={todo.completed}
              onChange={() => dispatch({ type: 'toggle', id: todo.id })}
            />
            <span style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
              {todo.text}
            </span>
            <button onClick={() => dispatch({ type: 'delete', id: todo.id })}>削除</button>
          </li>
        ))}
      </ul>

      <button onClick={() => dispatch({ type: 'clearCompleted' })}>
        完了済みを削除
      </button>
    </div>
  );
}

useStateとuseReducerの使い分け

状況 推奨
独立したシンプルな値 useState
複数のstateが連動して変化する useReducer
更新ロジックが複雑 useReducer
ロジックをコンポーネントから分離したい useReducer
テストを書きやすくしたい useReducer(reducerは純粋関数なのでテストが容易)

reducerをテストする

reducerは純粋関数(同じ入力 → 同じ出力)なので、簡単にテストできます。

describe('todoReducer', () => {
  it('add アクションでToDoが追加される', () => {
    const state = initialState;
    const next = todoReducer(state, { type: 'add', text: '買い物' });
    expect(next.todos).toHaveLength(1);
    expect(next.todos[0].text).toBe('買い物');
  });

  it('toggle アクションで完了状態が反転する', () => {
    const state = { ...initialState, todos: [{ id: 1, text: 'test', completed: false }] };
    const next = todoReducer(state, { type: 'toggle', id: 1 });
    expect(next.todos[0].completed).toBe(true);
  });
});
たける
たける Redux的な考え方ですか? action・dispatch・reducerって用語が一緒で。
りこ
りこ Reduxのコアコンセプトと同じ。ReduxはuseReducerをアプリ全体に拡張して、さらにミドルウェアやDevToolsを追加したライブラリ。理解したならReduxを学ぶコストは大きく下がる。
ユナ
ユナ reducerが純粋関数というのはテスト設計でも重要。同じinputに同じoutputが保証されるので、UIを描画せずロジックだけ単体テストできる。

まとめ

  • useReducer(state, action) => newStatereducer関数で状態を管理する
  • 複数のstateが連動する・更新ロジックが複雑な場合に useState より適している
  • TypeScriptのDiscriminated Union型でactionの型を安全に定義する
  • reducerは純粋関数なのでテストしやすい

次の第7回では、DOM参照やレンダリングをトリガーしない値の保持に使う useRef を学びます。