【テクニカル・上級編】 useStateとuseReducerの使い分け基準 – React実践ガイド

useStateの限界とuseReducerへの昇格:アーキテククトが下すべき「状態管理」の重大な意思決定

こんにちは。日々、無数のコンポーネントツリーとV8エンジン上でうごめくメモリの断片化に頭を悩ませているフロントエンド・チーフアーキテクトの私だ。

さて、君たちはReactでコードを書くとき、まず真っ先に何をインポートする?
もちろん、`useState`だよね。間違いない。コンポーネントが生まれてから消えるまで、その「記憶」を保持するための最もプリミティブで美しいAPIだ。シンプルで、直感的で、初心者からシニアまで誰もが最初に手にする麻薬のようなフックだ。

だが、少し胸に手を当てて考えてみてほしい。
1つのコンポーネントの中に、`useState`が5つも6つも並び、ある状態の変化が他の3つの状態に依存し、ハンドラー関数の中で `setA(prev => …)` と `setB(…)` が複雑に入り交じり、挙句の果てに「なぜかレンダリングが2回走る」「意図しないタイミングで古いステートを参照してバグる(Stale Closureの悪夢)」といった泥沼にハマった経験はないだろうか?

そう、君はすでにその時、「`useState`の限界点」を突破している。

今回は、数々の修羅場をくぐり抜けてきた我々シニアエンジニアが、いつ、どのようなアーキテクチャ的判断に基づいて `useState` から `useReducer` へ「昇格(Migration)」させるべきか、その境界線と内部挙動の真実を徹底的に解き明かしていこう。

—

1. なぜ `useState` は複雑な状態管理で破綻するのか?

まず、Reactの内部挙動に少しだけ踏み込んでみよう。
`useState` で更新関数(Setter)を呼ぶとき、Reactは内部のファイバー(Fiber)ノードに新しい状態キュー(Update Queue)を積む。複数の `useState` をバラバラに更新すると、それぞれの更新ごとに非同期的なバッチ処理(Batching)が試みられるが、状態間の「論理的な整合性」を保証するのは完全に開発者の手腕に委ねられることになる。

散らばった状態が招く「不整合なレンダリングの隙間」

例えば、ショッピングカートの画面を想像してほしい。

  • `selectedItem`(選択中の商品)
  • `quantity`(数量)
  • `isApplyingCoupon`(クーポン適用中か)
  • `discountRate`(割引率)

これらを個別の `useState` で管理した瞬間、コードベースには「あり得ない状態の組み合わせ(Impossible States)」が生まれる余地が誕生する。
「クーポン適用中なのに、割引率が0のまま数量だけが変更された瞬間」といった、Reactのライフサイクル上の中間状態が、ユーザーの目に触れる、あるいは予期せぬエフェクト(`useEffect`)のトリガーを引いてしまうのだ。

これはバグの温床であり、保守性の観点から見ても最悪のアンチパターンだと言わざるを得ない。

—

2. useState から useReducer へ移行すべき「4つの判断基準」

では、どのタイミングで `useReducer` という重厚長大に見える(実は非常にエレガントな)仕組みに切り替えるべきなのか。私の現場における判断基準は以下の4つに集約される。

1. 状態の数が「3つ」を超え、かつ互いに密結合している場合
個別のプリミティブ値ではなく、1つのドメインモデル(オブジェクト)としてまとめるべきデータ群である場合。
2. 「次の状態」を算出するために「前の複数の状態」を参照する必要がある場合
ロジックが複雑化し、コールバック地獄(`setA(a => …)` の入れ子や連鎖)が発生している場合。
3. 状態遷移のパターン(アクション)に名前をつけ、ビジネスロジックをコンポーネントから分離したい場合
「なぜその状態が変わったのか」という意図(Intent)をコードとして明示したい場合。
4. テストを純粋関数(Reducer)単位でサクッと書きたい場合
Reactのレンダリングコンテキストを汚さずに、ビジネスロジックの単体テストを極限まで容易にしたい場合。

もし、上記のどれか一つでも満たしているなら、君は今すぐ `useReducer` へのリファクタリングを検討すべきだ。

—

3. 実践:アンチパターンな `useState` と、洗練された `useReducer` の比較

百聞は一見にしかず。具体的なコードでその違いを見せよう。
ここでは、ある複雑なフォームと非同期処理を伴うステップウィザードの状態管理を例にする。

❌ アンチパターン:useStateの乱用によるカオス

import { useState } from ‘react’;

// 複数のuseStateが散らばり、状態の整合性を保つのが困難な例
export function BadFormWizard() {
const [step, setStep] = useState(1);
const [username, setUsername] = useState(”);
const [email, setEmail] = useState(”);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);

// 関連する状態を一箇所で更新しようとして、ハンドラーが肥大化する
const handleSubmitStep1 = async (name: string, mail: string) => {
setIsLoading(true);
setError(null);
try {
// 何らかのバリデーションやAPIコールを想定
await
setUsername(name);
setEmail(mail);
setStep(2); // ここでバラバラにsetStateを呼ぶため、不要な再レンダリングや順序依存のバグリスクが生じる
} catch (err) {
setError(‘エラーが発生しました’);
} finally {
setIsLoading(false);
}
};

return (

{/ 画面の構築 /}

);
}

このコードの何が問題か? `setUsername` と `setEmail` と `setStep` が個別に走るため、Reactは必要のない中間レンダリングを挟む可能性があり、さらにエラーハンドリングやローディング状態の管理が散逸している。

—

✔️ 昇格版:useReducerによる堅牢なアーキテクチャ

これを `useReducer` で書き換えてみよう。状態の遷移はすべて「アクション(Action)」という明確なメッセージとして定義され、Reducerという「純粋関数(Pure Function)」に閉じ込められる。

import { useReducer } from ‘react’;

// 1. 状態の型定義(単一の信頼できる情報源: Single Source of Truth)
interface WizardState {
step: number;
username: string;
email: string;
isLoading: boolean;
error: string | null;
}

// 2. アクションの型定義(Discriminated Unionを活用した型安全な設計)
type WizardAction =
| { type: ‘START_SUBMIT’ }
| { type: ‘STEP_1_SUCCESS’; payload: { username: string; email: string } }
| { type: ‘SET_ERROR’; payload: string }
| { type: ‘RESET’ };

// 3. 状態遷移を完全に司る純粋なReducer関数
// Reactのレンダリングサイクルから切り離されているため、Jestなどのテストが極めて容易
function wizardReducer(state: WizardState, action: WizardAction): WizardState {
switch (action.type) {
case ‘START_SUBMIT’:
return {
…state,
isLoading: true,
error: null, // ローディング開始時はエラーをクリア
};
case ‘STEP_1_SUCCESS’:
return {
…state,
isLoading: false,
username: action.payload.username,
email: action.payload.email,
step: 2, // 状態の整合性を保ったまま一度に更新
};
case ‘SET_ERROR’:
return {
…state,
isLoading: false,
error: action.payload,
};
case ‘RESET’:
return initialState;
default:
// 万が一、未知のアクションが来た場合の型安全なガード
const exhaustiveCheck: never = action;
return state;
}
}

const initialState: WizardState = {
step: 1,
username: ”,
email: ”,
isLoading: false,
error: null,
};

export function GoodFormWizard() {
// useReducerの導入。dispatchをコンポーネントに渡すだけで、複雑なロジックが隠蔽される
const [state, dispatch] = useReducer(wizardReducer, initialState);

const handleSubmitStep1 = async (name: string, mail: string) => {
dispatch({ type: ‘START_SUBMIT’ });
try {
// 非同期処理(API通信など)
await new Promise((resolve) => setTimeout(resolve, 1000));

// 成功時はアクションを発行するだけ
dispatch({
type: ‘STEP_1_SUCCESS’,
payload: { username: name, email: mail },
});
} catch (err) {
dispatch({ type: ‘SET_ERROR’, payload: ‘通信に失敗しました’ });
}
};

return (

現在のステップ: {state.step}

{state.isLoading &&

処理中…

}
{state.error &&

{state.error}

}
{/ ボタンやフォーム要素 /}

);
}

このコードの美しさに気づいただろうか?
コンポーネント側(`GoodFormWizard`)は、「どうやって状態を更新するか(ビジネスロジック)」の詳細から完全に解放され、ただ「何をしたいか(`dispatch({ type: ‘…’ })`)」を宣言するだけに留まっている。

—

4. アーキテクチャ的メリット:パフォーマンスと保守性の極限

シニアエンジニアとして、`useReducer` を推す理由はコードの綺麗さだけではない。メモリ効率とパフォーマンスの観点からも大きなアドバンテージがある。

1. `useCallback` との抜群の相性(メモ化の最適化)

`useState` を複数使っている場合、それらを更新するハンドラーの中で他のステートを参照すると、どうしても依存配列(Dependency Array)にそのステートを含める必要が出てくる。結果として、ステートが変わるたびにハンドラー関数が再生成され、子コンポーネントへの無駄な伝播(Prop Drillingによる再レンダリング)を引き起こしやすい。

しかし、`useReducer` の `dispatch` 関数は、Reactの仕様によって「レンダリングを跨いで絶対に変化しない(Referential Stability)」ことが保証されている。
つまり、`dispatch` を子コンポーネントに渡す場合、子側の `React.memo` や `useCallback` の最適化が非常に安定し、不要な再レンダリングの連鎖を断ち切ることができるのだ。

2. テスト駆動開発(TDD)の圧倒的親和性

Reducerは、外部のDOMやReactのコンテキストに依存しない「純粋関数(Pure Function)」だ。
つまり、React Testing Libraryを使って重いコンポーネントをレンダリングしなくても、JestやVitestなどのテストランナーで以下のように極めて高速な単体テストを記述できる。

// Reducerの単体テストの例(Reactのレンダリング不要)
describe(‘wizardReducer’, () => {
it(‘STEP_1_SUCCESSが正しく状態を更新すること’, () => {
const prevState = { …initialState, isLoading: true };
const action = {
type: ‘STEP_1_SUCCESS’ as const,
payload: { username: ‘GeekDeveloper’, email: ‘geek@example.com’ },
};

const nextState = wizardReducer(prevState, action);

expect(nextState.isLoadingtoBe(false));
expect(nextState.step).toBe(2);
expect(nextState.username).toBe(‘GeekDeveloper’);
});
});

このテストの実行速度はミリ秒単位だ。CI/CDパイプラインの負荷を劇的に下げ、アプリケーションの堅牢性を担保できる。

—

5. まとめ:プロフェッショナルとしての選択

`useState` は悪ではない。それは軽量で、単一のプリミティブや簡単なトグル状態を扱うには最高のツールだ。しかし、アプリケーションが成長し、ビジネスロジックが複雑化するにつれて、そのシンプルさは「技術的負債」へと姿を変える。

もし君が、「あっちを直せばこっちが壊れる」ような状態のスパゲッティに頭を抱えているなら、迷わず `useReducer` への昇格を検討してほしい。

「状態の変更意図(Action)を明確にし、遷移の責任(Reducer)を分離する」

このアーキテクチャの原則を守るだけで、君の書くReactコードは、大規模開発にも耐えうる圧倒的に堅牢で美しいものへと生まれ変わるはずだ。

さあ、エディタを開き、散らばった `useState` たちを美しくリファクタリングしようじゃないか。

コメント

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