useReducerで、関係するstateの更新をまとめる方法
送信中・エラー・送信済みのように、いっしょに変わる 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回目は成功するようにしてあります。
// 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 は持ちません。入力が空のあいだは、ボタンを押せないようにしています。
「こんにちは」と入力して送信し、失敗したあとで、もう一度送信しました。
--- 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つの関数に書きます。
// 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 で「何が起きたか」だけを伝えます。
// 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}
/>
);
}
流れを図にすると、次のようになります。

reducer に state のロジックをまとめるページでも、イベントハンドラは action で「何が起きたか」だけを伝え、state をどう変えるかは reducer が決める、と説明されています。action の type には、'failed' のように、起きたことを表す名前を付けるのが慣例です(同じページの、action についての注記)。
useState の版と同じ操作をした結果です。
--- 1b. useReducer にまとめる(ContactFormReducer) ---
> 「こんにちは」と入力して送信(1回目は失敗する)
送信中 画面: 入力="こんにちは" ボタン=無効 表示=[送信中…]
失敗後 画面: 入力="こんにちは" ボタン=有効 表示=[送信できませんでした]
> もう一度送信(2回目は成功する)
送信中 画面: 入力="こんにちは" ボタン=無効 表示=[送信中…]
成功後 画面: 入力="" ボタン=無効 表示=[送信しました]
2回目の送信を始めた時点でエラーが消え、成功後は「送信しました」だけが出ました。エラーを消す処理は、'submitted' と 'edited' の case に書いてあります。イベントハンドラの側で覚えておく必要はありません。
Chromium で2つのフォームを上下に並べ、上のフォーム、下のフォームの順に、2回ずつ送信した様子が次の GIF です。

送信の処理は 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 を間違えたコードも用意しました。
// typo.tsx(抜粋)
<button onClick={() => dispatch({ type: 'submited' })}>つづりの間違い</button>
<button onClick={() => dispatch({ type: 'failed' })}>message がない</button>
$ 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 を使わずに、そのまま呼んで確かめられます。
// 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 で実行した結果です。
ok - 入力すると text が変わる
ok - 失敗のあとに送信すると、エラーが消えて送信中になる
ok - 成功すると、入力欄が空になりエラーもない
ok - 前の state のオブジェクトは変わっていない
useState と useReducer の比べ方の説明でも、reducer はコンポーネントに依存しない純粋な関数なので、取り出して単独でテストできる、とされています。ふだんはコンポーネントを実際に近い環境でテストするのがよく、state の更新が込み入っているときに、reducer のテストが役に立つ、という位置づけです。
つまずきやすい点
dispatch の直後に state を読んでも、前の値のまま
ボタンを押したときに dispatch({ type: 'submitted' }) を呼び、その直後に state.status を記録しました。
[描画 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 を表示しています。
--- 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回だけ呼びました。
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 は純粋な関数にし、送信などの処理はイベントハンドラに書きます。





