React.js

ReactのuseReducer入門:TypeScriptで買い物リストの状態更新を整理する

この記事でわかること

React公式リファレンスを参考に、useReducerで買い物リストを実装。TypeScriptのaction型、dispatch、純粋な更新関数、追加・購入済み切り替え・削除を学びます。

入力値やチェック状態を扱うだけならuseStateが便利です。追加・完了・削除のように更新の種類が増えてきたら、useReducerで更新ルールを一か所へ集める方法があります。この記事では買い物リストを実装し、state、action、reducerの役割を確認します。

useReducerの役割を理解する

state・action・reducerの関係

stateは現在のデータ、actionは「何をしたいか」を表す値、reducerは現在のstateとactionから次のstateを計算する関数です。コンポーネントはdispatchでactionを渡し、画面を新しいstateから組み立てます。

この例では、商品の配列をstate、追加・切り替え・削除をactionにします。入力中の商品名は別のuseStateで管理します。すべての値を一つのreducerへまとめる必要はありません。

実行環境を用意する

ViteのReact・TypeScriptプロジェクトで実行できます。準備がまだの場合はReact.js入門記事を参照してください。ここで作るのはブラウザーのメモリー上で動く練習用リストで、再読み込みすると追加データは消えます。

TypeScriptで更新ルールを書く

shoppingReducer.tsで型とreducerを定義する

src/shoppingReducer.tsを作成します。actionを判別可能なユニオン型にすると、追加にはitem、切り替えと削除にはidが必要だと型で表せます。

export type Item = {
  id: string;
  name: string;
  done: boolean;
};

export type Action =
  | { type: 'added'; item: Item }
  | { type: 'toggled'; id: string }
  | { type: 'removed'; id: string };

export const initialItems: Item[] = [
  { id: 'milk', name: '牛乳', done: false },
  { id: 'bread', name: 'パン', done: true },
];

function assertNever(_value: never): never {
  throw new Error('未対応のアクションです');
}

export function shoppingReducer(state: Item[], action: Action): Item[] {
  switch (action.type) {
    case 'added':
      return [...state, action.item];
    case 'toggled':
      return state.map((item) =>
        item.id === action.id ? { ...item, done: !item.done } : item
      );
    case 'removed':
      return state.filter((item) => item.id !== action.id);
    default:
      return assertNever(action);
  }
}

元のstateを変更しない

pushで配列に追加したり、item.doneを直接変更したりせず、新しい配列やオブジェクトを返します。変更しない要素は同じオブジェクトを再利用できます。

reducerは、同じstateとactionから同じ結果を計算する関数として書きます。通信、ストレージ保存、乱数によるID生成は、この中に入れません。追加するIDは次のコンポーネントのイベント処理で生成してactionへ含めます。

neverで分岐の追加漏れを見つける

assertNeverの引数はneverです。すべてのactionを処理すると、残りの型はneverになります。後からActionへ種類を追加して対応するcaseを書き忘れると、assertNever(action)で型エラーとなり、分岐漏れに気づけます。

これはTypeScriptで書いた呼び出し元のチェックです。外部APIから受け取るJSONが正しいactionかどうかまで、実行時に検証する仕組みではありません。

dispatchで画面の操作を伝える

ShoppingList.tsxを実装する

src/ShoppingList.tsxを作成します。フォーム送信で追加し、チェックボックスで購入済み状態を切り替え、ボタンで削除します。削除ボタンの読み上げ名には商品名を含めます。

import { useReducer, useState, type FormEvent } from 'react';
import { initialItems, shoppingReducer } from './shoppingReducer';

export function ShoppingList() {
  const [items, dispatch] = useReducer(shoppingReducer, initialItems);
  const [name, setName] = useState('');

  function handleSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const trimmedName = name.trim();
    if (!trimmedName) return;

    dispatch({
      type: 'added',
      item: { id: crypto.randomUUID(), name: trimmedName, done: false },
    });
    setName('');
  }

  const remaining = items.filter((item) => !item.done).length;

  return (
    <section aria-labelledby="shopping-heading">
      <h2 id="shopping-heading">買い物リスト</h2>
      <form onSubmit={handleSubmit}>
        <label>
          商品名
          <input value={name} onChange={(event) => setName(event.target.value)} />
        </label>
        <button type="submit" disabled={!name.trim()}>追加する</button>
      </form>
      <p role="status">全{items.length}件・未購入{remaining}件</p>
      {items.length === 0 ? <p>買うものを追加してください。</p> : (
        <ul>
          {items.map((item) => (
            <li key={item.id}>
              <label>
                <input
                  type="checkbox"
                  checked={item.done}
                  onChange={() => dispatch({ type: 'toggled', id: item.id })}
                />
                {item.name}:{item.done ? '購入済み' : '未購入'}
              </label>
              <button
                type="button"
                aria-label={`${item.name}を削除`}
                onClick={() => dispatch({ type: 'removed', id: item.id })}
              >
                削除
              </button>
            </li>
          ))}
        </ul>
      )}
    </section>
  );
}

crypto.randomUUID()はHTTPSやlocalhostなどの安全なコンテキストで利用します。対応ブラウザーを確認してください。この例ではクライアント側で一時的な商品IDを作っています。サーバーへ保存する設計なら、永続化するIDの決め方も合わせて検討します。

App.tsxから表示する

src/App.tsxを次の内容に置き換えます。Viteテンプレートのsrc/main.tsxはそのまま利用できます。見た目はプロジェクトのCSSに従います。

import { ShoppingList } from './ShoppingList';

export default function App() {
  return (
    <main>
      <h1>買い物リストで学ぶuseReducer</h1>
      <ShoppingList />
    </main>
  );
}

動作を確認する

追加・切り替え・削除を試す

  • 初期状態は全2件、未購入1件です。
  • 空欄や空白だけの商品名は追加できません。
  • 「りんご」を追加すると全3件になり、入力欄は空になります。
  • 未購入の商品をチェックすると、未購入数が1減ります。
  • 削除ボタンで対象の商品だけが消えます。
  • すべて削除すると、追加を促す案内が表示されます。

追加と削除を繰り返しても、残っている商品の購入済み状態が別の商品へ移らないことを確かめます。行のkeyには配列の位置ではなく、商品のIDを使っています。

ビルドと更新関数を確認する

Viteプロジェクトで次のコマンドを実行します。

npm run build

reducerはReactを描画せずに呼べる通常の関数です。テストする際は、追加後の要素数だけでなく、元の配列が変更されていないことや、指定したIDだけが切り替わることを確認すると、更新ルールの誤りを見つけやすくなります。

よくある疑問

dispatch直後にstateが変わらないのはなぜですか

dispatchは次のレンダーに向けた更新を要求します。呼び出したイベント処理の途中で、現在のitems変数が書き換わるわけではありません。表示は更新後のレンダーで確認してください。

Strict Modeでreducerが複数回呼ばれても大丈夫ですか

開発中のStrict Modeでは、reducerが追加で呼ばれることがあります。元のstateを変更せず、乱数や通信を含めない純粋な更新処理なら、追加呼び出しによって商品が二重に追加されることはありません。

useStateから必ず移行するべきですか

単純な入力値や開閉状態にはuseStateが読みやすい場合があります。更新の種類が増えた、同じ更新ルールが複数のイベントに散らばった、更新処理だけを検証したい、といった理由があるときにuseReducerを検討します。

関連記事と参考資料

基本Hooksから復習する

stateの更新やContextによる共有を復習する場合は、Reactの基本Hooks入門を参照してください。Contextと組み合わせる設計へ進む前に、まずはコンポーネント内でstateとactionの流れを確認しましょう。

公式ドキュメント

y.
WRITTEN BY

y_ymo10

SEの部屋で、JavaScript・TypeScript・React.js・Next.jsの開発ノートを公開しています。

ほかの開発ノートを読む →