こんにちは。君もそろそろ、実務で「`useState`の海」に溺れかけ、コードが肥大化して夜中に冷や汗をかくフェーズを抜け出したい頃なんじゃないかな。
よくあるんだよ、チュートリアルを卒業した中級エンジニアが、いざ現場の複雑なフォームや非同期のステート管理を任された途端に、あちこちで散発的に`setState`を呼び出して、アプリ全体が「いつ・どこで・何によって」状態が変わったのか分からないブラックボックスと化す現象がね。
今回は、そんなカオスに陥ったコードベースを救うための、「アクションオブジェクトの設計とペイロードの受け渡し」について話をしよう。Reduxのボイラープレートの話をしているわけじゃない。Reactの標準的な`useReducer`や、複雑なカスタムフックの内部で、いかに美しく、そして型安全に状態遷移をコントロールするかという、実務の現場で明日から使える極意だ。
—
なぜ「闇雲な`useState`」は破綻するのか?
実務でよく見る悪夢のようなコードから始めよう。
// 良くある、あちこちに散らばった状態更新
const [user, setUser] = useState(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
// ログインボタンを押したときにあちこち書き換える
const handleLogin = async (credentials) => {
setIsLoading(true);
setError(null);
try {
const data = await api.login(credentials);
setUser(data);
setIsLoading(false);
} catch (err) {
setError(err.message);
setIsLoading(false); // ああ、finallyに入れ忘れたりするんだよな…
}
};
これ、一見動くけど、状態の遷移が「散在」しているんだよね。コンポーネントが大きくなるにつれて、「ローディング中にエラーをクリアし忘れた」「成功時にユーザー情報を入れるのを漏らした」といった、状態の不整合(バグ)の温床になる。
ここで立ち返るべきなのが、「状態遷移の集約」だ。
「何が起きたか(アクション)」を明確にし、「どう状態を変えるか(レジューサー)」を1箇所に閉じ込める。これが、大規模開発を生き抜くための基本戦略なんだよ。
—
フローの裏側:Reactとブラウザはイベントをどう処理しているか?
ここで少し、ブラウザとReactの裏側の動きに目を向けてみよう。
私たちがボタンをクリックしたり、APIのレスポンスを受け取ったりして状態更新を走らせるとき、Reactは内部のファイバーツリー(Fiber Tree)を更新し、次回のレンダリングスケジュールを組む。ここで重要なのは、「状態更新は非同期であり、バッチ処理される」ということだ。
バラバラのタイミングで `setIsLoading` や `setUser` を呼ぶと、Reactはそれぞれの更新に対して再レンダリングのスケジュールを組もうとしてムダが生じる(あるいは古いクロージャをキャッチして意図しない挙動を引き起こす)。
しかし、アクションオブジェクトを一度生成し、それをトリガーに `useReducer`(あるいはそれに類する仕組み)で状態を1回でスパッと更新してやれば、Reactはひとつのトランザクションとして処理をまとめ上げられる。ブラウザのメインスレッドへの負荷を最小限に抑え、描画のフリーズを防ぐことができるというわけだ。裏側の仕組みを理解していると、設計の美しさがそのままパフォーマンスに直結することが腹落ちするはずだよ。
—
実践!型安全なアクション設計のベストプラクティス
前置きはこれくらいにして、現場で即採用できる「型安全なアクションオブジェクトの設計」を見ていこう。
ポイントは2つ。
1. `type` プロパティで「何が起きたか」を厳格に定義する。
2. `payload` プロパティで「それに伴うデータ」を型安全に包む。
TypeScriptのDiscriminated Union(判別可能なユニオン型)をフル活用するのが、現代のReactフロントエンドエンジニアの嗜みだ。
以下のコードをよく見てほしい。そのままコピーして、君のプロジェクトのエディタに貼り付ければすぐに動くはずだ。
import React, { useReducer } from ‘react’;
// ==========================================
// 1. 状態(State)の型定義
// ==========================================
interface UserState {
data: { id: string; name: string; email: string } | null;
status: ‘idle’ | ‘loading’ | ‘succeeded’ | ‘failed’;
error: string | null;
}
const initialState: UserState = {
data: null,
status: ‘idle’,
error: null,
};
// ==========================================
// 2. アクション(Action)の型定義(Discriminated Union)
// ==========================================
// 各アクションがどんな payload を持つべきかをここで完全に縛り上げる
type UserAction =
| { type: ‘FETCH_USER_START’ }
| { type: ‘FETCH_USER_SUCCESS’; payload: { id: string; name: string; email: string } }
| { type: ‘FETCH_USER_FAILURE’; payload: string } // エラーメッセージを文字列で渡す
| { type: ‘LOGOUT’ };
// ==========================================
// 3. レジューサー関数(純粋関数として実装)
// ==========================================
// 状態がどう変化するかをここに完全に閉じ込める
const userReducer = (state: UserState, action: UserAction): UserState => {
switch (action.type) {
case ‘FETCH_USER_START’:
return {
…state,
status: ‘loading’,
error: null, // 読み込み開始時は前回のエラーを必ずクリアする!こういう細部がプロの仕事。
};
case ‘FETCH_USER_SUCCESS’:
return {
…state,
status: ‘succeeded’,
data: action.payload, // TypeScriptが payload の型を自動的に推論してくれる(最高!)
error: null,
};
case ‘FETCH_USER_FAILURE’:
return {
…state,
status: ‘failed’,
error: action.payload, // action.payload は string 型であることが保証される
};
case ‘LOGOUT’:
return initialState; // 初期状態へ綺麗にリセット
default:
// 万が一、定義外のアクションが来た場合の網羅性チェック(Exhaustive Check)
const exhaustiveCheck: never = action;
return state;
}
};
// ==========================================
// 4. コンポーネントでの実践
// ==========================================
export const UserProfile: React.FC = () => {
const [state, dispatch] = useReducer(userReducer, initialState);
const fetchUserData = async () => {
// 1. 処理開始のアクションをディスパッチ
dispatch({ type: ‘FETCH_USER_START’ });
try {
// ダミーの非同期処理(本来はAPIコール)
const response = await new Promise<{ id: string; name: string; email: string }>((resolve) =>
setTimeout(() => resolve({ id: ‘1’, name: ‘Yamada Taro’, email: ‘yamada@example.com’ }), 1000)
);
// 2. 成功時のアクションにペイロードを乗せてディスパッチ
dispatch({ type: ‘FETCH_USER_SUCCESS’, payload: response });
} catch (err) {
// 3. 失敗時のアクションをディスパッチ
dispatch({ type: ‘FETCH_USER_FAILURE’, payload: ‘ユーザー情報の取得に失敗しました。’ });
}
};
return (
ユーザープロフィール
{state.status === ‘idle’ &&
ボタンを押してデータを取得してください。
}
{state.status === ‘loading’ &&
ローディング中…
}
{state.status === ‘failed’ &&
エラー: {state.error}
}
{state.status === ‘succeeded’ && state.data && (
ID: {state.data.id}
名前: {state.data.name}
メール: {state.data.email}
)}
{state.status !== ‘loading’ && state.status !== ‘succeeded’ && (
)}
);
};
—
シニアからの実践アドバイス:ここだけは押さえろ!
このコードをそのままチームに持ち帰ったとき、意識してほしい実務での勘所をいくつか伝授しよう。
1. ペイロードには「生データ」をそのまま突っ込むな、必要な形に正規化しろ
APIから返ってきたレスポンス(DTO)を、そのまま脳死で `payload` に入れるのはやめよう。コンポーネント側やレジューサーの手前で、フロントエンド側が扱いやすいドメインモデルにマッピングしてから payload に乗せるのが、保守性を高める秘訣だ。
2. `default` ケースでの `never` 型による網羅性チェックをサボるな
さっきのコードにも入れたけど、`const exhaustiveCheck: never = action;` という記述。これを入れておくと、将来的に `UserAction` に新しいアクション(例: `FETCH_USER_RETRY`)を追加し忘れたとき、TypeScriptのコンパイラが「おい、レジューサー側でこのアクションが処理されてないぞ!」とビルドエラーでブチギレて教えてくれる。これが型安全の醍醐味だ。
3. 「すべてをReducerにすべき」という宗教戦争に囚われるな
「フォームの入力値の1文字単位の変更(`setInputText`)」など、コンポーネント内で完結するローカルなUIの状態まで `useReducer` で書こうとすると、逆にコードが冗長になって疲弊する。
「複数の状態が連動して変わるもの」「非同期処理を伴うもの」「バグの温になりやすい複雑なステート」に絞って、今回紹介したアクション設計を適用するのが、現場で最も費用対効果が高いアプローチなんだ。
—
さあ、どうだったかな?
アクションオブジェクトとペイロードの設計を少し厳格にするだけで、コードの見通しが劇的に変わり、「バグの潜む隙」が綺麗に消えていくのが実感できたはずだ。
明日からの君のプルリクエストが、より洗練されたものになることを期待しているよ。行き詰まったら、またいつでも相談に乗りに来るといい。

コメント