こんにちは。日夜、V8エンジンのガベージコレクションの挙動や、Reactのファイバーツリーの差分検出アルゴリズムに思いを馳せているフロントエンド・アーキテクチャの住人です。
さて、皆さんは日々の開発で `useState` の海原を泳ぎ、無数の状態変数を散らかしては、非同期処理の競合(Race Condition)や、意図しない再レンダリングの嵐に頭を抱えてはいないだろうか?
「ボタンを連打したらデータが巻き戻った」「stateの更新漏れで画面が宙ぶらりんになった」――そんな泥臭いバグの特効薬として、多くのエンジニアが `useReducer` という名の聖杯にたどり着く。
しかし、ただ `useReducer` を導入するだけでは不十分だ。そこにある「アクションオブジェクトの設計」という作法を誤れば、コードベースは一瞬にして型安全性のない技術的負債のゴミ捨て場と化す。
今回は、堅牢なReactアプリケーションを構築するための「アクションオブジェクトの設計とペイロードの受け渡し」について、ブラウザのランタイムの動きやメモリ効率、そしてTypeScriptによる型安全性の極限まで踏み込んで解き明かしていこう。
—
なぜ `useState` だけでは破綻するのか?
大規模なフォームや複雑なダッシュボードを構築する際、次のようなコードを書いたことはないだろうか。
// 危険な香りのするアンチパターン
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const handleUpdate = async (newData) => {
setLoading(true);
setError(null);
try {
const res = await api.update(newData);
// ここで非同期の競合が発生するリスクがある
setUser(res.data);
} catch (err) {
setError(err);
} finally {
setLoading(false);
}
};
このアプローチの最大の欠点は、「状態の遷移(Transition)がカプセル化されていない」点にある。複数の `setXxx` がバラバラに呼び出されるため、Reactの内部スケジューラはこれらを別々のキューとして処理し、無駄な再レンダリング(Re-rendering)を誘発する。React 18以降、自動バッチング(Automatic Batching)の恩恵はあるものの、ドメインロジックの意図がコンポーネントのあちこちに散らばる構造は、メンテナビリティの観点から最悪だ。
ここで登場するのが、状態の「更新の意図」をオブジェクトとして一元化する `useReducer`、そしてその中核をなすアクションオブジェクト(Action Object)の設計思想である。
—
アクションオブジェクトの基本構造:`type` と `payload`
Fluxアーキテクチャの精神を受け継ぐReactの `useReducer` では、状態遷移のトリガーを「何が起きたか(`type`)」と「そのデータは何か(`payload`)」という規約(Convention)に基づいて記述する。
// 標準的なアクションの型定義
type UserAction =
| { type: ‘FETCH_INIT’ }
| { type: ‘FETCH_SUCCESS’; payload: UserData }
| { type: ‘FETCH_FAILURE’; payload: string };
ここで重要なのは、「不可能な状態遷移を型レベルでコンパイルエラーにする」という執念だ。
例えば、`FETCH_SUCCESS` なのに `payload` が欠落していたり、存在しない `type` を指定したりした瞬間、TypeScriptのコンパイラが牙を剥くように設計する。これが、堅牢なアプリケーションを支える最初の防壁となる。
—
実践:型安全性を極限まで高めたReducerのアーキテクチャ
百聞は一見に如かず。実務レベルでそのまま耐えうる、メモリ効率と型安全性を考慮した堅牢なコンポーネントの設計を見ていこう。
import React, { useReducer, useCallback } from ‘react’;
// 1. ドメインモデルの定義
interface UserProfile {
id: string;
name: string;
email: string;
}
// 2. 状態の型定義(無効な状態の組み合わせを排除する)
type State =
| { status: ‘IDLE’; data: null; error: null }
| { status: ‘LOADING’; data: UserProfile | null; error: null } // 楽観的更新を考慮して前回のデータを保持することも
| { status: ‘SUCCESS’; data: UserProfile; error: null }
| { status: ‘ERROR’; data: null; error: string };
// 3. アクションオブジェクトのDiscriminated Union(判別可能ユニオン)
type Action =
| { type: ‘USER_FETCH_START’ }
| { type: ‘USER_FETCH_SUCCESS’; payload: UserProfile }
| { type: ‘USER_FETCH_ERROR’; payload: string }
| { type: ‘USER_UPDATE_FIELD’; payload: Partial
// 4. 純粋関数としてのReducer(副作用を一切排除する)
function userReducer(state: State, action: Action): State {
switch (action.type) {
case ‘USER_FETCH_START’:
return { status: ‘LOADING’, data: state.data, error: null };
case ‘USER_FETCH_SUCCESS’:
return { status: ‘SUCCESS’, data: action.payload, error: null };
case ‘USER_FETCH_ERROR’:
return { status: ‘ERROR’, data: null, error: action.payload };
case ‘USER_UPDATE_FIELD’:
// SUCCESS状態でのみフィールドの部分更新を許可する型ガード的アプローチ
if (state.status === ‘SUCCESS’) {
return {
…state,
data: { …state.data, …action.payload },
};
}
return state;
default:
// 網羅性チェック(Exhaustiveness Checking)
const _: never = action;
return state;
}
}
export const UserProfileManager: React.FC<{ userId: string }> = ({ userId }) => {
const [state, dispatch] = useReducer(userReducer, {
status: ‘IDLE’,
data: null,
error: null,
});
// メモリ効率と子コンポーネントへの不要な再描画を防ぐためのuseCallback
const handleRefresh = useCallback(async () => {
dispatch({ type: ‘USER_FETCH_START’ });
try {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) throw new Error(‘Failed to fetch user’);
const data: UserProfile = await response.json();
dispatch({ type: ‘USER_FETCH_SUCCESS’, payload: data });
} catch (err) {
dispatch({
type: ‘USER_FETCH_ERROR’,
payload: err instanceof Error ? err.message : ‘Unknown error’
});
}
}, [userId]);
return (
User Profile Manager
{state.status === ‘LOADING’ &&
Loading…
}
{state.status === ‘ERROR’ &&
Error: {state.error}
}
{state.status === ‘SUCCESS’ && (
Name: {state.data.name}
Email: {state.data.email}
)}
);
};
—
アーキテクチャの深層:なぜこの設計が優れているのか?
上記のコードには、単なる「動くコード」を超えた、パフォーマンスと保守性のための幾重もの工夫が施されている。
1. 不可能な状態(Impossible States)を型で消し去る
多くの開発者がやりがちなミスは、`isLoading`, `isError`, `data` をバラバラの `useState` で管理することだ。それだと理論上、「`isLoading: true` かつ `isError: true` かつ `data: 有効な値`」というカオスな状態が生まれ得る。
今回の実装では、状態を判別可能ユニオン(Discriminated Union)で表現しているため、`status === ‘SUCCESS’` の時のみ `data` が絶対に存在することが型システムによって保証される。ランタイムエラーの温床をコンパイル時に根絶やしにしているわけだ。
2. ペイロードの構造化とメモリ効率
アクションの `payload` に巨大なオブジェクトや不要な参照を詰め込むのは、V8エンジンのガベージコレクタ(GC)に無用な負荷をかける。
例えば、`USER_UPDATE_FIELD` では `Partial
3. 非同期処理とディスパッチの分離
Reducer自体は「純粋関数(Pure Function)」でなければならない。Reducer内で非同期通信(APIコールなど)を行っては絶対にダメだ。なぜなら、副作用が混入すると、同じ入力に対して同じ出力が返るという参照透過性が失われ、タイムトラベルデバッグやテストが不可能になるからだ。
非同期処理はコンポーネント側(あるいはカスタムフック内)でハンドリングし、その結果(成功・失敗)をピュアなアクションオブジェクト包んで `dispatch` する。この責務の分離(Separation of Concerns)こそが、堅牢なアーキテクチャの鉄則である。
—
チーフアーキテクチャからの提言
Reactにおける状態管理は、単に「画面を動かすための手段」ではない。それは、アプリケーション全体のデータフローとドメインモデルを表現する「設計図」そのものだ。
`useState` の洪水に溺れかけたら、立ち止まってその状態変数の関係性を見つめ直してほしい。
「この状態とこの状態は同時に存在し得ないのではないか?」
「この更新の意図を、明確な `type` と `payload` を持つアクションとして定義できないか?」
その問いの先にある `useReducer` と洗練されたアクション設計こそが、あなたのコードベースを泥臭いスパゲッティコードから、美しく拡張性のある芸術品へと昇華させる唯一の道なのだ。
さあ、エディタを開き、型安全な世界へダイブしよう。

コメント