【実務・中級編】 useReducerとuseContextの組み合わせによるグローバル状態管理 – React実践ガイド

やあ。今日も今日とてコンポーネントの海を泳ぎ回っていることだな。

「Reduxを入れるほどでもないけれど、`useState`と`prop drilling(バケツリレー)`の嵐でコンポーネントがスパゲッティ化してきた……」
中級の壁にぶつかったエンジニアから、毎日のようにこんな悲鳴を聞く。

外部の状態管理ライブラリ(Zustand, Jotai, Redux Toolkitなど)に頼るのも一つの手だ。だが、ちょっと待ってほしい。Reactが標準で備えている`useReducer`と`useContext`を正しく組み合わせるだけで、外部依存を増やさずに、堅牢で予測可能なグローバル状態管理基盤が作れるとしたらどうだろう?

今回は、実務の現場で「お、こいつ分かってるな」と思われる、この王道パターンの設計と実装の極意を授けよう。手と頭を動かしながら、しっかりとついてきてほしい。

—

なぜ `useState` だけでは限界が来るのか?

現場でよく見かけるアンチパターンがこれだ。Appコンポーネントのトップ層に巨大な`useState`を置き、それを深さ5階層ある子孫コンポーネントにひたすらプロパティとして渡していく。

// 悪夢のバケツリレー(Prop Drilling)
function App() {
const [user, setUser] = useState(null);
const [theme, setTheme] = useState(‘light’);
const [notifications, setNotifications] = useState([]);

// これらを延々と子に渡し続ける地獄…
return

;
}

これの何が辛いかって?
1. 状態の更新ロジックがコンポーネントのあちこちに散らばる:あっちのボタンでは`setUser`をこう呼び、こっちのモーダルでは……とやっているうちに、バグの温床になる。
2. 再描画(レンダリング)の最適化が困難になる:親の状態が変わるたびに関係ない子まで再評価される。
3. テストが書きづらい:UIとビジネスロジックが密結合しすぎる。

ここで登場するのが、「状態の更新ロジックを1箇所に集約する `useReducer`」と、「それをバケツリレーなしでツリー全体に配給する `useContext`」のコンボだ。

—

`useReducer` + `useContext` の基本設計アーキテクチャ

このパターンの肝は、「状態(State)」と「アクションを発行する関数(Dispatch)」を別々のContextに分離することにある。

なぜ分けるのか?
もし状態とDispatchを1つのContextにまとめると、状態が1ミリ変化しただけで、Dispatchしか使っていない(状態は読んでいない)末端のコンポーネントまで無駄に再レンダリングされてしまうからだ。これはパフォーマンスチューニングの観点から絶対に避けなければならない。

それでは、実務でそのまま使えるクリーンな実装を見ていこう。

—

現場で使える!実践コード例:認証・テーマ管理ストア

テーマとして、ユーザーのログイン状態とアプリのテーマ(light/dark)を管理するグローバルストアを作ってみよう。

1. 型定義とReducerの作成

まずは状態の形(State)と、起こりうるイベント(Action)、そして状態を純粋関数として更新するReducerを定義する。

import React, { createContext, useContext, useReducer, ReactNode } from ‘react’;

// — 1. 状態とアクションの型定義 —
type Theme = ‘light’ | ‘dark’;

interface User {
id: string;
name: string;
email: string;
}

interface AppState {
theme: Theme;
user: User | null;
isLoading: boolean;
}

type AppAction =
| { type: ‘TOGGLE_THEME’ }
| { type: ‘LOGIN_SUCCESS’; payload: User }
| { type: ‘LOGOUT’ }
| { type: ‘SET_LOADING’; payload: boolean };

// 初期状態
const initialState: AppState = {
theme: ‘light’,
user: null,
isLoading: false,
};

// — 2. Reducer関数(純粋関数として実装する) —
// ブラウザの裏側でも、この関数がアクションを受け取って新しい状態を予測可能に返却する
function appReducer(state: AppState, action: AppAction): AppState {
switch (action.type) {
case ‘TOGGLE_THEME’:
return {
…state,
theme: state.theme === ‘light’ ? ‘dark’ : ‘light’,
};
case ‘LOGIN_SUCCESS’:
return {
…state,
user: action.payload,
isLoading: false,
};
case ‘LOGOUT’:
return {
…state,
user: null,
isLoading: false,
};
case ‘SET_LOADING’:
return {
…state,
isLoading: action.payload,
};
default:
// 万が一、定義外のアクションが来たら型安全に弾く
const exhaustiveCheck: never = action;
throw new Error(`Unhandled action type: ${exhaustiveCheck}`);
}
}

2. Contextの作成とProviderの実装

次に、Contextを「State用」と「Dispatch用」の2つに分けて作成し、カスタムProviderコンポーネントで包み込む。

// — 3. Contextの作成(分割するのがシニアの技) —
const AppStateContext = createContext(undefined);
const AppDispatchContext = createContext | undefined>(undefined);

// — 4. Providerコンポーネント —
interface AppProviderProps {
children: ReactNode;
}

export const AppProvider: React.FC = ({ children }) => {
const [state, dispatch] = useReducer(appReducer, initialState);

return (


{children}


);
};

3. 安全性を高めるカスタムフックの実装

Contextをそのまま使うと、Providerの外で呼び出されたときに`undefined`になり、型エラーや実行時エラーの元になる。専用のカスタムフックを作ってガードしよう。

// — 5. メンテナンス性を高めるカスタムフック —
export const useAppState = (): AppState => {
const context = useContext(AppStateContext);
if (context === undefined) {
throw new Error(‘useAppState must be used within an AppProvider’);
}
return context;
};

export const useAppDispatch = (): React.Dispatch => {
const context = useContext(AppDispatchContext);
if (context === undefined) {
throw new Error(‘useAppDispatch must be used within an AppProvider’);
}
return context;
};

—

実際にコンポーネントから使ってみる

この設計の美しいところは、「状態を読むだけのコンポーネント」と「アクションを発行するだけのコンポーネント」を綺麗に分離できる点にある。

// 状態を読むだけのコンポーネント(テーマ変更では再描画されない!最高効率)
const UserProfile: React.FC = () => {
const { user, isLoading } = useAppState();

if (isLoading) return

読み込み中…

;
if (!user) return

ログインしていません

;

return

ようこそ、{user.name} さん!

;
};

// アクションを発行するだけのコンポーネント
const ThemeToggleButton: React.FC = () => {
const { theme } = useAppState(); // テーマの見た目を変えるためにstateも取得
const dispatch = useAppDispatch();

return (

);
};

—

チーフアーキテクトからの実務アドバイス

このパターンを現場に導入する際、シニアとしていくつか押さえておいてほしい注意点がある。

1. 非同期処理(API通信など)はReducerに持ち込まない

  • `useReducer`のReducer関数は「純粋関数(Pure Function)」であるべきだ。サイドエフェクト(APIリクエスト、`setTimeout`、`localStorage`の読み書きなど)をReducerの中に直接書くのは御法度。非同期処理はコンポーネント側やカスタムフック(Thunk的なアプローチや単純なasync/await関数)で行い、結果だけを`dispatch`しよう。

2. 何でもかんでもグローバルにしない

  • 「バケツリレーが面倒だから」という理由で、フォームの入力値(`useState`レベルの一過性の状態)までContextにねじ込む愚行はやめたまえ。グローバル状態にしていいのは、「アプリ全体で共有され、かつ複数の独立したコンポーネントから参照・変更されるデータ(認証、テーマ、言語設定など)」だけだ。

—

まとめ

`useReducer`と`useContext`の組み合わせは、外部ライブラリのボイルプレート(お決まりのコード)や学習コストを嫌う現場において、非常に強力な武器になる。

Reactが裏側でどう差分検出をし、どうコンポーネントツリーを伝搬させるか。その仕組みさえ理解していれば、この標準機能だけで大規模なアプリケーションでも十分に戦い抜くことができる。

さあ、今日のコードレビューでは、無駄なプロパティバケツリレーを見つけたら、このパターンへのリファクタリングを優しく提案してみてくれ。君のチームのコードベースが、一歩洗練されるはずだ。

コメント

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