【テクニカル・上級編】 useReducerの基本構文とdispatchの役割 – React実践ガイド

useReducerとdispatchの深層:複雑な状態遷移を制圧するアーキテクチャ

フロントエンドの規模が肥大化するにつれ、私たちは決まって同じ壁にぶつかる。無数の `useState` が散らばり、ある状態の更新が別の状態の非同期的な競合を生み、コンポーネントの内部が「いつ、どこで、なぜ変更されたのか分からない」スパゲッティコードと化していくあの悪夢だ。

もし君が、単なる「カウンターアプリの作り方」ではなく、プロダクション環境で耐えうる堅牢な状態管理の設計図を求めているなら、答えはすでにある。`useReducer` だ。

今回は、Reactの心臓部におけるメモリ効率、レンダリング最適化、そして `dispatch` が持つ真のメカニズムに焦点を当て、この強力なフックをアーキテクチャレベルで解剖していこう。

—

1. なぜ `useState` の限界は突然やってくるのか

`useState` はプリミティブな値や単純なオブジェクトを扱うには最適だ。しかし、状態の更新が「前の状態の複数のプロパティに依存している場合」や「アクションの種類によって更新ロジックが分岐する場合」、コンポーネント内やカスタムフック内に無数のセッター(`setA`, `setB`, `setC`…)が乱立することになる。

これは単にコードが汚染されるだけではない。以下のような深刻なアーキテクチャ上の課題を生む。

  • ビジネスロジックのUI層への癒着: 状態をどう変更すべきかというルール(Reducer的ロジック)が、イベントハンドラや `useEffect` の中に散らばり、テストが困難になる。
  • 非同期競合(Race Condition)の頻発: 複数の `setState` が連続して呼ばれた際、バッチ処理の裏側で古いクロージャの値を参照し続け、意図しない状態の上書きが発生する。

ここで登場するのが `useReducer` である。状態遷移の全責任を純粋関数(Reducer)に委譲し、コンポーネント側は「何が起きたか(Action)」を通知するだけに留める。この関心の分離こそが、スケールするReactアプリケーションの生命線なのだ。

—

2. `useReducer` の基本構文と、ブラウザエンジンをも唸らせる関数的アプローチ

まずは基本構文のおさらいだが、単なるAPIの使い方の確認ではない。それぞれの引数と戻り値が、Reactのレンダリングパイプラインにおいてどのような意味を持つのかを理解する必要がある。

const [state, dispatch] = useReducer(reducer, initialArg, init);

1. `reducer` (状態遷移関数): `(prevState, action) => newState` のシグネチャを持つ純粋関数(Pure Function)でなければならない。サイドエフェクト(APIコール、乱数生成、Date.now()など)は厳禁だ。同じ入力に対して常に同じ出力を返すことで、Reactの内部最適化や将来的なConcurrent Features(Time Slicingなど)の恩恵を最大限に受けることができる。
2. `initialArg` と `init` (遅延初期化): 巨大な初期状態を構築する場合、直接オブジェクトを渡すと毎回のレンダーで評価コストが発生する。第3引数に `init` 関数を渡すことで、初回のマウント時のみ初期化ロジックを遅延実行させ、メモリ効率と初期表示速度を最適化できる。

実践:堅牢な状態管理の実装例

複雑な非同期フェーズや入力バリデーションを含むフォームの状態管理を例に、型安全な `useReducer` の実装を見てみよう。

import React, { useReducer, useCallback } from ‘f5’;

// 1. 状態の型定義
interface FormState {
username: string;
email: string;
isSubmitting: boolean;
error: string | null;
}

// 2. アクションの型定義(Discriminated Unionを活用し、型安全性を担保)
type FormAction =
| { type: ‘FIELD_CHANGED’; field: ‘username’ | ‘email’; value: string }
| { type: ‘SUBMIT_START’ }
| { type: ‘SUBMIT_SUCCESS’ }
| { type: ‘SUBMIT_FAILURE’; payload: string }
| { type: ‘RESET’ };

// 初期状態
const initialState: FormState = {
username: ”,
email: ”,
isSubmitting: false,
error: null,
};

// 3. 純粋関数としての Reducer
// ここで副作用を行ってはならない。完全に予測可能な状態遷移のみを記述する。
function formReducer(state: FormState, action: FormAction): FormState {
switch (action.type) {
case ‘FIELD_CHANGED’:
return {
…state,
// 計算プロパティ名を用いて動的にフィールドを更新
[action.field]: action.value,
// 入力があったらエラー表示はクリアする親切設計
error: null,
};
case ‘SUBMIT_START’:
return {
…state,
isSubmitting: true,
error: null,
};
case ‘SUBMIT_SUCCESS’:
return {
…initialState, // 成功時は初期状態へリセット
};
case ‘SUBMIT_FAILURE’:
return {
…state,
isSubmitting: false,
error: action.payload,
};
case ‘RESET’:
return initialState;
default:
// 万が一、定義外のアクションが来た場合の型ガード
const exhaustiveCheck: never = action;
return state;
}
}

export const UserProfileForm: React.FC = () => {
const [state, dispatch] = useReducer(formReducer, initialState);

// dispatch はアイデンティティが安定しているため、依存陣列に含める必要が基本的にはない
const handleChange = (field: ‘username’ | ‘email’, value: string) => {
dispatch({ type: ‘FIELD_CHANGED’, field, value });
};

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
dispatch({ type: ‘SUBMIT_START’ });

try {
// 擬似的なAPIコール
await new Promise((resolve, reject) => {
setTimeout(() => {
if (state.username === ‘error’) reject(new Error(‘サーバーエラーが発生しました’));
else resolve(true);
}, 1000);
});
dispatch({ type: ‘SUBMIT_SUCCESS’ });
} catch (err: any) {
dispatch({ type: ‘SUBMIT_FAILURE’, payload: err.message });
}
};

return (

{state.error &&

{state.error}

}

);
};

—

3. `dispatch` の正体と、レンダリング最適化の深層

多くの開発者が誤解しているが、`dispatch` 関数自体は状態をその場で書き換えるわけではない。Reactのファイバーツリー(Fiber Tree)に対して「アクションのディスパッチ(予約)」を行っているに過ぎない。

`dispatch` の安定性とパフォーマンスへの寄与

Reactコアチームの設計における傑作の一つが、`dispatch` 関数の参照同一性(Referential Identity)が完全に保証されているという点だ。

`useState` のセッター関数も同様の性質を持つが、`useReducer` の `dispatch` は、複雑な子コンポーネント群に対して「コールバック地獄」を回避するための強力な武器になる。

// 親から子へ dispatch をそのまま渡しても、子コンポーネントの不要な再レンダリングを引き起こさない
// (dispatch 自体の参照が変わらないため、React.memo との相性が抜群に良い)

もしこれが複数の個別のセッター関数(`setName`, `setEmail`, `setAge`…)であれば、それらを子に渡すために `useCallback` で囲むボイラープレート地獄に陥るか、親のレンダリングの度に子の再レンダリングを引き起こすかの二択を迫られていただろう。`dispatch` を1つ渡すだけで、子コンポーネント側で自由に任意のアクションを発火させつつ、親の再レンダリングコストを最小限に抑えられる。

バッチ処理(Batching)と非同期の罠への対策

React 18以降、自動バッチング(Automatic Batching)が導入され、イベントハンドラや非同期処理の内部であっても、連続した `dispatch` 呼び出しは一つのレンダリングパスにまとめられる。

しかし、ここで注意すべきは 「reducer内での非同期処理は許されない」 という鉄則だ。もし reducer の中で `setTimeout` や `fetch` を呼ぼうものなら、予測不能なタイミングで状態が書き換わり、Reactのレンダリングスケジューラが崩壊する。

非同期処理を挟む場合は、上記コード例のように 「アクションの粒度を分ける(START -> SUCCESS / FAILURE)」 こと。これが、非同期の競合や状態の不整合を防ぐための最もエレガントかつ堅牢なパターンである。

—

4. チーフアーキテクトからの提言:いつ `useReducer` を選ぶべきか

すべての状態を `useReducer` で管理する必要はない。単純なトグルフラグ(`isOpen` など)にまで reducer を持ち出すのは、過剰設計(Over-engineering)であり、単にコードの認知負荷を上げるだけだ。

以下の条件に当てはまった瞬間、君は `useState` を捨てて `useReducer` を選択すべきだ。

1. 3つ以上の関連するプリミティブ値が、常に連動して更新される場合
2. 次の状態が、直前の複雑な条件分岐(ビジネスロジック)に依存している場合
3. コンポーネントのロジックをUIから完全に切り離し、純粋関数として単体テスト(Unit Test)を行いたい場合

`useReducer` と `dispatch` をマスターすることは、単にReactのフックを使いこなすことではない。それは、アプリケーションのデータフローを厳格に統制し、カオスになりがちなフロントエンドのアーキテクチャに美しさと拡張性をもたらすための、最も確実なアプローチなのだ。

コメント

タイトルとURLをコピーしました