useReducerで、関係するstateの更新をまとめる方法

​​React​TypeScript,useReducer,useState,フォーム

送信中・エラー・送信済みのように、いっしょに変わる state を useState で別々に持つと、どこかで1つ更新し忘れて、ありえない組み合わせが画面に出ることがあります。useReducer を使うと、「何が起きたか」ごとに次の state を1つの関数(reducer)で決めるので、更新の漏れが起きにくくなり、型やテストでも確かめやすくなります。

筆者は検証環境で、同じ問い合わせフォームを useState と useReducer で書き、同じ操作をして比べました。あわせて、TypeScript の型で書き漏らしを見つける方法、reducer だけをテストする方法、つまずきやすい点も確かめています。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2、jsdom 29.1.1 です。.tsx を tsc で型チェックしてから esbuild で変換し、jsdom の上で動かしました。StrictMode は、回数を数えた検証でだけ使っています。GIF と図は、Chromium 141 を Playwright 1.56.1 で動かして作りました(GIF への変換は ffmpeg 6.1.1)。

useState で別々に持つと、エラーの表示が残ったままになる

検証には、文を書いて送る問い合わせフォームを使いました。入力した文(text)、送信中か(isSending)、エラーの文(error)、送信済みか(isSent)の4つを、それぞれ useState で持ちます。send は検証用の送信関数で、1回目は失敗し、2回目は成功するようにしてあります。

tsx
// ContactFormState.tsx
import { useState } from 'react';
import { FormView } from './FormView';

// useState を4つ使い、送信の処理の中でそれぞれを更新する
export function ContactFormState({ send }: { send: (text: string) => Promise<void> }) {
  const [text, setText] = useState('');
  const [isSending, setIsSending] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [isSent, setIsSent] = useState(false);

  async function handleSubmit() {
    setIsSending(true);
    try {
      await send(text);
      setIsSent(true);
      setText('');
    } catch {
      setError('送信できませんでした');
    } finally {
      setIsSending(false);
    }
  }

  return (
    <FormView
      text={text}
      isSending={isSending}
      error={error}
      isSent={isSent}
      onChange={setText}
      onSubmit={handleSubmit}
    />
  );
}

FormView は、受け取った値をもとに、入力欄と「送信」ボタン、「送信中…」「送信できませんでした」「送信しました」の表示を出すだけのコンポーネントです。state は持ちません。入力が空のあいだは、ボタンを押せないようにしています。

「こんにちは」と入力して送信し、失敗したあとで、もう一度送信しました。

text
--- 1a. useState を4つ使う(ContactFormState) ---
  > 「こんにちは」と入力して送信(1回目は失敗する)
  送信中  画面: 入力="こんにちは" ボタン=無効 表示=[送信中…]
  失敗後  画面: 入力="こんにちは" ボタン=有効 表示=[送信できませんでした]
  > もう一度送信(2回目は成功する)
  送信中  画面: 入力="こんにちは" ボタン=無効 表示=[送信中…] [送信できませんでした]
  成功後  画面: 入力="" ボタン=無効 表示=[送信できませんでした] [送信しました]

2回目の送信中も、成功したあとも、1回目のエラーが残りました。成功後は「送信できませんでした」と「送信しました」が並んで出ています。送信を始めるときに setError(null) を呼んでいないためです。

setError(null) を足せばこのずれは消えるはずです(この版は試していません)。ただ、送信、成功、失敗、入力のそれぞれの場面で、4つの state のどれを変えるかを毎回思い出す必要があります。state の構造の選び方のページにある、矛盾した state を避ける説明でも、送信中と送信済みの state を別々に持つと、両方が true になる「ありえない」状態が起こりうる、と書かれています。

reducer に「何が起きたら、state をどう変えるか」をまとめる

同じフォームを useReducer で書き直しました。まず、4つの state を1つのオブジェクトにまとめ、送信中と送信済みは status という1つの値にしました。次に、起きたこと(action)の種類を型で並べ、それぞれで次の state をどうするかを、reducer という1つの関数に書きます。

ts
// formReducer.ts
export type FormState = {
  text: string;
  status: 'editing' | 'sending' | 'sent' | 'error';
  error: string | null;
};

export type FormAction =
  | { type: 'edited'; text: string }
  | { type: 'submitted' }
  | { type: 'succeeded' }
  | { type: 'failed'; message: string };

export const initialState: FormState = { text: '', status: 'editing', error: null };

// 「何が起きたか」(action)を受け取り、次の state を返す
export function formReducer(state: FormState, action: FormAction): FormState {
  switch (action.type) {
    case 'edited':
      return { ...state, text: action.text, status: 'editing', error: null };
    case 'submitted':
      return { ...state, status: 'sending', error: null };
    case 'succeeded':
      return { text: '', status: 'sent', error: null };
    case 'failed':
      return { ...state, status: 'error', error: action.message };
    default: {
      // action.type の書き漏らしがあると、ここで型エラーになる
      const unknown: never = action;
      throw new Error(`Unknown action: ${JSON.stringify(unknown)}`);
    }
  }
}

コンポーネントの側では、useReducer に reducer と初めの state を渡し、イベントハンドラからは dispatch で「何が起きたか」だけを伝えます。

tsx
// ContactFormReducer.tsx
import { useReducer } from 'react';
import { FormView } from './FormView';
import { formReducer, initialState } from './formReducer';

// state の更新は formReducer にまとめ、ここでは何が起きたかだけを伝える
export function ContactFormReducer({ send }: { send: (text: string) => Promise<void> }) {
  const [state, dispatch] = useReducer(formReducer, initialState);

  async function handleSubmit() {
    dispatch({ type: 'submitted' });
    try {
      await send(state.text);
      dispatch({ type: 'succeeded' });
    } catch {
      dispatch({ type: 'failed', message: '送信できませんでした' });
    }
  }

  return (
    <FormView
      text={state.text}
      isSending={state.status === 'sending'}
      error={state.error}
      isSent={state.status === 'sent'}
      onChange={(text) => dispatch({ type: 'edited', text })}
      onSubmit={handleSubmit}
    />
  );
}

流れを図にすると、次のようになります。

イベントハンドラが dispatch で action を渡し、reducer が次の state を返し、React が描画し直して画面にエラーだけが表示される、という4段の流れ図
dispatch から画面が変わるまでの流れ。state をどう変えるかは reducer だけで決める

reducer に state のロジックをまとめるページでも、イベントハンドラは action で「何が起きたか」だけを伝え、state をどう変えるかは reducer が決める、と説明されています。action の type には、'failed' のように、起きたことを表す名前を付けるのが慣例です(同じページの、action についての注記)。

useState の版と同じ操作をした結果です。

text
--- 1b. useReducer にまとめる(ContactFormReducer) ---
  > 「こんにちは」と入力して送信(1回目は失敗する)
  送信中  画面: 入力="こんにちは" ボタン=無効 表示=[送信中…]
  失敗後  画面: 入力="こんにちは" ボタン=有効 表示=[送信できませんでした]
  > もう一度送信(2回目は成功する)
  送信中  画面: 入力="こんにちは" ボタン=無効 表示=[送信中…]
  成功後  画面: 入力="" ボタン=無効 表示=[送信しました]

2回目の送信を始めた時点でエラーが消え、成功後は「送信しました」だけが出ました。エラーを消す処理は、'submitted' と 'edited' の case に書いてあります。イベントハンドラの側で覚えておく必要はありません。

Chromium で2つのフォームを上下に並べ、上のフォーム、下のフォームの順に、2回ずつ送信した様子が次の GIF です。

上の useState のフォームでは、2回目の送信中も成功後も「送信できませんでした」が残る。下の useReducer のフォームでは、2回目の送信を始めるとエラーが消え、成功後は「送信しました」だけが出る
useState 版と useReducer 版で、1回目は失敗、2回目は成功する送信をしたときの表示の動き

送信の処理は reducer に書かない

send を呼ぶ処理は、reducer ではなくイベントハンドラに残しています。reducer の書き方の注意によると、reducer は描画の途中で呼ばれるため、純粋な関数でなければなりません。リクエストを送ったり、タイマーを仕掛けたりしてはいけないとされています。オブジェクトや配列も、書き換えずに新しく作って返します。

同じ注意には、action は1回のユーザーの操作を表すように作る、ともあります。5つの欄があるフォームでリセットを押したときは、set_field を5回送るのではなく、reset_form を1回送る、という例です。

型で、書き漏らしと書き間違いを見つける

formReducer の最後の default では、action を never 型の変数に入れています。すべての type を case で扱っていれば、ここに来る action は残らないので、型エラーにはなりません。

試しに、FormAction に { type: 'reset' } を足し、reducer に case を書き足さないまま型チェックをしました。あわせて、dispatch に渡す action を間違えたコードも用意しました。

tsx
// typo.tsx(抜粋)
<button onClick={() => dispatch({ type: 'submited' })}>つづりの間違い</button>
<button onClick={() => dispatch({ type: 'failed' })}>message がない</button>
text
$ npx tsc -p tsconfig.bad.json; echo "exit code: $?"
bad/formReducerWithReset.ts(30,13): error TS2322: Type '{ type: "reset"; }' is not assignable to type 'never'.
bad/typo.tsx(9,41): error TS2820: Type '"submited"' is not assignable to type '"edited" | "failed" | "submitted" | "succeeded"'. Did you mean '"submitted"'?
bad/typo.tsx(10,39): error TS2345: Argument of type '{ type: "failed"; }' is not assignable to parameter of type 'FormAction'.
  Property 'message' is missing in type '{ type: "failed"; }' but required in type '{ type: "failed"; message: string; }'.
exit code: 1

reset の case の書き漏らしは never の行で、type のつづりの間違いと message の渡し忘れは dispatch の行で、それぞれ型エラーになりました。useReducer のリファレンスのトラブルシューティングでは、どの case にも当たらない action を見つけるために、switch の外でエラーを投げる書き方を紹介し、TypeScript のような型チェッカーでも見つけられる、としています。

reducer だけを、React なしでテストする

reducer は、前の state と action を受け取って次の state を返すだけの関数です。React を使わずに、そのまま呼んで確かめられます。

ts
// 2-reducer-only.ts(抜粋。import は省略)
// React を使わずに、reducer だけを呼んで確かめる
const failed: FormState = { text: 'こんにちは', status: 'error', error: '送信できませんでした' };

const cases: [string, FormState, Parameters<typeof formReducer>[1], FormState][] = [
  ['入力すると text が変わる', initialState, { type: 'edited', text: 'こ' },
    { text: 'こ', status: 'editing', error: null }],
  ['失敗のあとに送信すると、エラーが消えて送信中になる', failed, { type: 'submitted' },
    { text: 'こんにちは', status: 'sending', error: null }],
  ['成功すると、入力欄が空になりエラーもない', { ...failed, status: 'sending', error: null }, { type: 'succeeded' },
    { text: '', status: 'sent', error: null }],
];

for (const [name, state, action, expected] of cases) {
  const next = formReducer(state, action);
  assert.deepEqual(next, expected);
  console.log(`ok - ${name}`);
}
// reducer は前の state を書き換えない
assert.equal(failed.status, 'error');
console.log('ok - 前の state のオブジェクトは変わっていない');

前の state、action、期待する次の state の組を3つ並べて確かめ、最後に、前の state のオブジェクトが書き換わっていないことも確かめました。esbuild で変換し、node で実行した結果です。

text
ok - 入力すると text が変わる
ok - 失敗のあとに送信すると、エラーが消えて送信中になる
ok - 成功すると、入力欄が空になりエラーもない
ok - 前の state のオブジェクトは変わっていない

useState と useReducer の比べ方の説明でも、reducer はコンポーネントに依存しない純粋な関数なので、取り出して単独でテストできる、とされています。ふだんはコンポーネントを実際に近い環境でテストするのがよく、state の更新が込み入っているときに、reducer のテストが役に立つ、という位置づけです。

つまずきやすい点

dispatch の直後に state を読んでも、前の値のまま

ボタンを押したときに dispatch({ type: 'submitted' }) を呼び、その直後に state.status を記録しました。

text
  [描画 status=editing]
  [dispatch の直後に読んだ state.status=editing]
  [reducer を自分で呼んだ結果の status=sending]
  [描画 status=sending]

dispatch の直後に読んでも editing のままで、次の描画で sending になりました。dispatch 関数の注意事項にあるとおり、dispatch が変えるのは次の描画での state です。次の値が必要なら、トラブルシューティングの例のように、reducer を自分で呼んで計算できます。検証でも、formReducer(state, action).status は sending でした。

なお、ContactFormReducer の send(state.text) も、送信を押したときの描画の state を読んでいます。'submitted' の case では text を変えないので、送る文に影響はありません。

state を書き換えて返すと、画面が変わらない

reducer の中で state.status = 'sending' と書き換えて同じオブジェクトを返す版と、{ ...state, status: 'sending' } で新しいオブジェクトを返す版を比べました。ボタンには state.status を表示しています。

text
--- 4a. state を書き換えて返す ---
  [描画 status=editing]
  [描画 status=sending]
  クリック後のボタンの表示: editing
--- 4b. 新しいオブジェクトを返す ---
  [描画 status=editing]
  [描画 status=sending]
  クリック後のボタンの表示: sending
注意

reducer の中で state を書き換えて同じオブジェクトを返すと、画面が更新されません。検証では、コンポーネントの関数は書き換わった値(sending)で呼ばれたのに、ボタンの表示は editing のままでした。dispatch 関数の注意事項によると、次の state が今の state と Object.is で同じなら、React は描画を省きます。そのときもコンポーネントを呼ぶことはありますが、結果は使われません。オブジェクトや配列は、画面が更新されないときの説明にあるように、新しく作って返します。

StrictMode では reducer が2回呼ばれる

呼ばれた回数を数える reducer を用意し、dispatch を1回だけ呼びました。

text
StrictMode なし: dispatch 1回で reducer が呼ばれた回数=1 表示=sending
StrictMode あり: dispatch 1回で reducer が呼ばれた回数=2 表示=sending

StrictMode では、1回の dispatch で reducer が2回呼ばれました。useReducer のリファレンスの注意事項によると、これは純粋でない reducer に気づかせるための、開発時だけの動きです。片方の結果は捨てられるので、reducer が純粋なら動きは変わりません。表示も、StrictMode なしと同じ sending でした。reducer の中に送信のような処理を書くと、開発中はそれも2回動くはずです(この記事では試していません)。

useState のままでよい場面もある

useState と useReducer の比べ方の説明では、コードの量、読みやすさ、デバッグ、テスト、好みの5つの観点で比べています。更新が単純なうちは useState のほうが書くコードが少なく、読みやすいとされています。そのうえで、あるコンポーネントで state の更新の誤りによる不具合がよく起きるなら reducer を使う、という勧め方をしています。1つのコンポーネントで useState と useReducer を混ぜてもかまいません。

送信中と送信済みが同時に true になるような矛盾だけなら、useState のまま1つの status にまとめる直し方が、矛盾した state を避ける説明で紹介されています。ただ、今回のエラーの残りは setError(null) を呼んでいないことが原因なので、status にまとめるだけでは直りません。どの出来事でどの state を変えるかを1か所に集めておくと、こうした書き忘れに気づきやすくなる、というのが筆者の考えです。

ポイント

いっしょに変わる state は、1つのオブジェクトにまとめて useReducer で持ちます。イベントハンドラは dispatch で「何が起きたか」だけを伝え、次の state は reducer が決めます。action を TypeScript の型で並べ、default で never に入れておくと、case の書き漏らしが型エラーになります。reducer は純粋な関数にし、送信などの処理はイベントハンドラに書きます。

​Posted by 86