📝

【React修行日記】useReducerと型定義

に公開

学習の目的

  • useReducerの基本について理解する
  • useState と useReducer の違いを把握する
  • 型定義を使った状態管理のメリットを学ぶ
  • 複雑な状態更新を整理してTodoリストを実装できるようになる

useReducerとは

useReducerは、Reactで状態管理を行うためのフックのひとつ。useStateと同じく、コンポーネントの状態を保持し、更新するために使う。
useStateは状態だけを管理するのに対して、useReducerは状態と状態を操作するための手段(関数)の両方を管理する。

シンプルな状態管理であればuseStateが直感的で扱いやすく、複雑な状態更新や複数の操作が関わる場合にはuseReducerを使うことで、状態更新のロジックを整理しやすくなる。

import { useReducer } from "react";

const [state, dispatch] = useReducer(reducer, initialArg, init?)
  • state
    • 現在の状態を表す変数
    • useState でいうところの状態変数と同じ
  • dispatch
    • 状態を更新するための関数
    • dispatch({ type: "ACTION_TYPE", ...payload })のようにactionを渡す
  • reducer
    • 状態をどう更新するかを決める関数
    • signature は基本的にこう:
      (state, action) => newState
      
      • state:現在の状態
      • action:dispatch で渡された命令
      • newState:更新後の状態を返す
  • initialArg
    • 初期状態を指定
    • 配列やオブジェクトなど、管理したい状態の形に合わせる
  • init? (オプション)
    • 初期状態を加工して設定したい場合に使用

useStateとuseReducerの違い(公式より)

  • コード量
    • useState:単純な場合はコード少なめ
    • useReducer:リデューサ関数と dispatch が必要 → 初めは多め。でも複数イベントで同じ更新がある場合は整理できる
  • 可読性
    • シンプルな状態更新 → useStateが読みやすい
    • 複雑な状態更新 → useReducerで「何を起こすか」と「どう変えるか」を分けられる
  • デバッグ
    • useReducerならリデューサ内にログを入れて「どのアクションで何が起きたか」が追いやすい
  • テスト
    • リデューサは純関数だからコンポーネントに依存せず単体テストが簡単
  • 使い分け
    • 好みや状況次第で使い分けOK
    • 複雑な状態更新がある場合に useReducer 推奨
    • 1つのコンポーネントで両方使うことも可能

https://ja.react.dev/learn/extracting-state-logic-into-a-reducer#comparing-usestate-and-usereducer

useReducer + 型定義で作るTodoリスト

今回は、useReducerと型定義を活用して簡単なTodoリストを作ってみた...!
【目的】
単純なuseStateでは管理が複雑になる状態を、reducerにまとめることでコードの可読性と保守性を高める

Todo 型と Action 型の定義

type Todo = {
  id: number;
  title: string;
  is_done: boolean;
};

type Action =
  | { type: "ADD"; title: string }
  | { type: "TOGGLE"; id: number }
  | { type: "DELETE"; id: number };

状態(state)がどういう形かどんなアクションで更新するかを明確にする。

reducer関数

function reducer(state: Todo[], action: Action): Todo[] {
  switch (action.type) {
    case "ADD":
      return [
        ...state,
        { id: Date.now(), title: action.title, is_done: false },
      ];
    case "TOGGLE":
      return state.map((todo) =>
        todo.id === action.id ? { ...todo, is_done: !todo.is_done } : todo
      );
    case "DELETE":
      return state.filter((todo) => todo.id !== action.id);
    default:
      return state;
  }
}

reducerで「stateの更新ルール」をまとめる。
今回のTodoリストでは、ADDTOGGLEDELETEの3つの操作を処理。
ここで、先ほどの型定義が効いてくる。
ADDではtitleTOGGLEではidが必要になるため、それぞれの型を定義しておくことで、reducerに渡されるactionが必ず正しい形を持つことを保証できる。

また、reducerでは状態は不変(immutable)に扱う必要がある。
状態を直接変更するとReactの再レンダリングや副作用の管理に問題が出る可能性があるため、
例えばTOGGLEでは直接stateを書き換えるのではなく、mapを使用して新しい配列を返している。

case "TOGGLE":
  return state.map((todo) =>
    todo.id === action.id ? { ...todo, is_done: !todo.is_done } : todo
  );

コンポーネント全体

export default function Todo() {
  const [todos, dispatch] = useReducer(reducer, []);
  const [input, setInput] = useState("");

  const handleAdd = () => {
    if (!input.trim()) return;
    dispatch({ type: "ADD", title: input });
    setInput("");
  };

  const handleToggle = (id: number) => {
    dispatch({ type: "TOGGLE", id });
  };

  const handleDelete = (id: number) => {
    dispatch({ type: "DELETE", id });
  };

  return (
    <div className="flex flex-col items-center justify-center gap-8">
      <h1>TODOリスト</h1>
      <div className="flex items-center gap-4 w-full">
        <Input value={input} onChange={(e) => setInput(e.target.value)} />
        <Button onClick={handleAdd}>追加</Button>
      </div>

      <ul className="flex flex-col gap-4 w-full">
        {todos.map((todo) => (
          <li key={todo.id} className="flex items-center g-4 justify-between">
            <div className="flex items-center gap-3">
              <Checkbox
                id={todo.title}
                checked={todo.is_done}
                onCheckedChange={() => handleToggle(todo.id)}
              />
              <Label
                className={todo.is_done ? "line-through text-gray-500" : ""}
                htmlFor={todo.title}
              >
                {todo.title}
              </Label>
            </div>
            <Button
              size={"sm"}
              variant={"secondary"}
              onClick={() => handleDelete(todo.id)}
            >
              削除
            </Button>
          </li>
        ))}
      </ul>
    </div>
  );
}

shadcn/uiを使用

各ハンドラはstate更新のルールをactionとしてreducerに渡す役割がある。
入力欄の文字列は単純なのでuseStateで管理。

補足: 空文字入力の処理

useReducerとは関係ないが、handleAddの以下の部分について

if (!input.trim()) return;

空文字はJavaScriptではfalseとして扱われるため、!input.trim()で空文字の時は真偽値をひっくり返してtrueにし、処理を中断している。

まとめ

  • useReduceruseStateとよく似ているが、複雑な操作を伴う場合に有効
  • 状態と更新ロジックをreducerにまとめることでコードが整理され、可読性・保守性が向上する
  • 型定義を使うことで、actionの形やstateの構造を型安全に管理できる

参考

https://ja.react.dev/reference/react/useReducer
https://ja.react.dev/learn/extracting-state-logic-into-a-reducer#comparing-usestate-and-usereducer

Discussion