【実務・中級編】 useStateとuseReducerの使い分け基準 – React実践ガイド

やあ、調子はどうだい?
最近、コードレビューをしていて一番よく見かける「モヤッとするアンチパターン」について話そうか。

新規機能の初期段階では `useState` でサクッと作っていたはずの状態が、要件の追加やバグ修正の波に揉まれるうちに、気づけばコンポーネント内に `setLoading` や `setError` や `setData` が乱れ打ちされ、あっちこっちで非同期の競合を起こしている……。心当たり、ないかい?

「そろそろ `useReducer` にリファクタリングした方がいいのかな、でも動いてるし触らぬ神に……」なんて日和っている中級エンジニアのキミに向けて、今日は「useStateの限界を見極め、useReducerへ優雅に移行するための判断基準と実務の知見」を叩き込んでやろう。

公式ドキュメントには書いていない、現場の泥臭い現実含めて徹底的に解説するから、コーヒーでも飲みながら聞いてくれ。

—

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

まず敵を知ることから始めよう。`useState` は素晴らしい。シンプルで、直感的で、ちょっとしたフラグ管理や単一の値を持つにはこれ以上ない選択肢だ。

しかし、次のような状態(State)を扱おうとした瞬間、`useState` は牙をむく。

1. 複数の状態が密接に連動している(例:フォームの入力値、バリデーションエラー、送信中フラグ、APIレスポンスのデータが互いに依存している)
2. 状態の更新が「前の状態」に複雑に依存しており、かつ非同期のタイミングが重なる
3. 「この状態の時は、あの状態をリセットしなきゃいけない」といったビジネスロジックがコンポーネントのあちこちに散らばる

これが俗に言う「ステート爆発(State Explosion)」だ。

例えば、よくある非同期データ取得の画面を作るとき、こんなコードを書いていないかい?

// 【危ういアンチパターンの例】
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);

const handleFetch = async () => {
setLoading(true);
setError(null);
try {
const result = await api.getData();
setData(result);
// うっかりここで loading を false にし忘れたり、
// 競合状態で古いレスポンスが後から返ってきたりする泥沼…
} catch (err) {
setError(err);
setData(null); // エラー時にデータをクリアするのを忘れたり!
} finally {
setLoading(false);
}
};

このアプローチの何がヤバいかと言うと、「正しい状態の組み合わせ(遷移ルール)」を担保する責任が、すべてUIコンポーネント側のハンド関数に委ねられている点だ。人間は誰しもミスをする。ある日は `setData(null)` を書き忘れ、ある日はネットワークが遅い環境でレースコンディションを踏み抜く。

ブラウザの裏側でReactは、複数の `setState` が呼ばれるとバッチ処理(再描画の最適化)を行ってくれるが、非同期処理の合間に散らばった `setState` は、時に予期せぬ中間レンダリングを引き起こし、UIの不整合を生む原因になる。

—

`useReducer` へ移行すべき「3つの明確な判断基準」

では、一体どのタイミングで `useReducer` に舵を切るべきなのか?
シニアとして、チームに胸を張って提案できる3つの明確な判断基準を授けよう。以下のいずれかに該当した瞬間が、リファクタリングのゴングだ。

1. 状態の更新ロジックが「3つ以上」の変数にまたがっているとき

`loading`, `error`, `data`, `selectedId`……これらが連動して動く場合、個別の `useState` で管理するのはすでに限界だ。これらを一つの「状態オブジェクト」としてまとめ、更新のルールを1箇所に集約するべきだ。

次のステートが「直前のステートの複数の値」に依存しているとき

「Aがtrueのとき、Bは必ずfalseになり、かつCには特定の初期値が入る」といった排他的なルールや複雑な条件分岐が増えてきたら、`useState` のセッター関数に渡すコールバック地獄(あるいはコンポーネント内の散らかったロジック)から抜け出す必要がある。

状態の更新パターンに「名前(意図)」をつけたいとき

`setCount(c => c + 1)` は分かりやすいが、`setIsSubmittingAndClearError(…)` なんて関数は誰も見たくないだろう。
`useReducer` を使えば、`{ type: ‘SUBMIT_START’ }` や `{ type: ‘FETCH_SUCCESS’, payload: data }` のように、「ユーザーが何をしたのか(あるいは何が起きたのか)」というドメインの意図(Intent)を明確にコードとして表現できる。 これが保守性においてどれほど強力か、実務経験者なら分かるはずだ。

—

現場で使える! `useState` から `useReducer` への華麗なる移行

百聞は一見にしかずだ。先ほどの「データ取得・ローディング・エラー管理」のアンチパターンを、`useReducer` を使って美しく、堅牢に書き換えたサンプルコードを見てみよう。

そのままコピペして、キミの開発しているアプリの骨組みとして使ってくれて構わない。

import React, { useReducer } from ‘react’;

// 1. 状態(State)の型定義と初期値
interface State {
data: T | null;
loading: boolean;
error: Error | null;
}

const initialState = {
data: null,
loading: false,
error: null,
};

// 2. アクション(Action)の型定義(ここで起こりうるイベントを完全網羅する)
type Action =
| { type: ‘FETCH_INIT’ }
| { type: ‘FETCH_SUCCESS’; payload: T }
| { type: ‘FETCH_FAILURE’; payload: Error };

// 3. 純粋関数としてのReducer(状態遷移のルールをここに完全封じ込めする)
// ※ ReactのReducerは必ず「純粋関数(同じ入力には必ず同じ出力を返す)」にするのが鉄則だ!
function dataFetchReducer(state: State, action: Action): State {
switch (action.type) {
case ‘FETCH_INIT’:
// データを取得し始める時は、必ずローディングをtrueにし、古いエラーは消す。
// これにより「エラーが残ったままローディング中になる」というバグを物理的に防げる。
return { …state, loading: true, error: null };

case ‘FETCH_SUCCESS’:
return { …state, loading: false, data: action.payload, error: null };

case ‘FETCH_FAILURE’:
// 失敗時はデータをクリアし、エラーを保持する
return { …state, loading: false, data: null, error: action.payload };

default:
// 万が一、定義外のアクションが飛んできた時の安全ネット
const exhaustiveCheck: never = action;
throw new Error(`Unhandled action type: ${exhaustiveCheck}`);
}
}

// 4. コンポーネントでの実践
export const UserProfileViewer = () => {
// useReducerの導入。これで状態管理とロジックが綺麗に分離される。
const [state, dispatch] = useReducer(dataFetchReducer, initialState);
const { data, loading, error } = state;

const handleFetchUser = async () => {
// コンポーネント側は「何が起きたか(Intent)」をdispatchするだけ!
// 複雑な状態の組み合わせをここで手動計算する必要はもうない。
dispatch({ type: ‘FETCH_INIT’ });

try {
// ダミーの非同期APIコール
const response = await fetch(‘https://jsonplaceholder.typicode.com/users/1’);
if (!response.ok) throw new Error(‘データの取得に失敗しました’);
const result = await response.json();

dispatch({ type: ‘FETCH_SUCCESS’, payload: result });
} catch (err) {
dispatch({ type: ‘FETCH_FAILURE’, payload: err instanceof Error ? err : new Error(‘不明なエラー’) });
}
};

return (

{error &&

エラー: {error.message}

}

{data && (

取得データ成功!

名前: {data.name}

メール: {data.email}

)}

);
};

このコードが美しい理由(シニアからの解説)

1. 状態遷移の「矛盾」が起き得ない構造
`fetch_INIT` アクションが走った瞬間に、内部で `error: null` が強制される。これにより、「エラーが表示されているのにローディングが動いている」というUIのバグが構造上発生しなくなる。
2. テストが圧倒的に容易になる
`dataFetchReducer` は外部依存のないただの「純粋関数」だ。Reactのレンダリングコンテキストすら不要で、単体テスト(JestやVitestなど)で `reducer(state, action)` を直接叩くだけで、あらゆる状態遷移のテストが秒速で書ける。
3. コンポーネントが「薄く」なる
コンポーネントの仕事は「イベントを検知して dispatch すること」と「受け取った state を描画すること」の2つに純化される。

—

シニアアーキテクチャからの最後の助言

「じゃあ、明日から全部の `useState` を `useReducer` に書き換えよう!」……なんて短絡的なことは言わないでくれ。それはそれで過剰設計(Over-engineering)という別の悪魔に取り憑かれることになる。

単なるモーダルの開閉フラグ(`isOpen`)や、インプットのテキスト保持(`text`)に `useReducer` を持ち出すのは、蚊を倒すために大砲を撃つようなものだ。コードのボイラープレート(記述量)が無駄に増えて、かえって見通しが悪くなる。

「シンプルなプリミティブ値には `useState` を。関連し合う複数の値が織りなす複雑な状態遷移には `useReducer` を」

このトレードオフを正確に嗅ぎ分け、チームメンバーが「おっ、ここを `useReducer` にしたのはナイス判断だな」と唸るような設計をできるのが、一歩先を行く中級からシニアへのパスポートだ。

さあ、キミの今のコードベースを見返して、そろそろ `useReducer` に救いを求めるべき「暴れん坊なコンポーネント」を探しに行こうか。幸運を祈る!

コメント

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