Reduxは本当に必要か? `useReducer` と `useContext` が織りなす、バニラReactの極限グローバル状態管理アーキテクチャ
こんにちは。日々、Reactのレンダリングツリーの最適化とメモリプロファイルの削減に命を燃やしているフロントエンド・アーキテクトです。
アプリケーションが成長するにつれて、誰もが一度はこの問題に直面します。「Propsのバケツリレー(Prop Drilling)が限界を迎えた。さて、どうするか?」と。
ここで反射的に Redux Toolkit や Zustand、Jotai といった外部ライブラリを導入するチームが多いですが、ちょっと待ってください。バンドルサイズを1バイトでも削りたい、あるいは外部依存のサプライチェーンリスクを極限まで排除したい高セキュリティな環境において、私たちはReactが標準で用意しているプリミティブだけでどこまで戦えるでしょうか?
答えは明確です。`useReducer` と `useContext` を正しく設計に落とし込みさえすれば、中規模から大規模一歩手前までのアプリケーションであれば、外部ライブラリなど一切不要で圧倒的に堅牢な状態管理基盤が構築できます。
しかし、ここに一つ大きな罠があります。この2つを素朴(ナイーブ)に組み合わせると、「Contextが更新されるたびに、アプリ全体の全コンポーネントが強制再レンダリングされる」というパフォーマンスの悪夢を引き起こします。
今回は、ブラウザのレンダリングパイプラインとReactの内部 reconciliation(差分検出処理)の挙動まで踏み込みながら、実務の戦場で生き抜くための極限のパターンを解剖していきましょう。
—
なぜ `useState` ではなく `useReducer` なのか?
単一のコンポーネント内であれば `useState` で事足りますが、グローバルに近い状態を複数の子孫から操作する場合、状態の遷移ロジック(State Transition Logic)がコンポーネントのあちこちに散らばるという最悪のアンチパターンを生みます。
`useReducer` を採用する理由は、「状態の更新責務をコンポーネントから完全に分離し、純粋関数(Reducer)としてカプセル化するため」です。
// 状態の型定義
type State = {
count: number;
user: { name: string; isLoggedIn: boolean } | null;
loading: boolean;
};
// アクションの型定義(Discriminated Unionによる型安全の担保)
type Action =
| { type: ‘INCREMENT’ }
| { type: ‘DECREMENT’ }
| { type: ‘SET_USER’; payload: { name: string } }
| { type: ‘LOGOUT’ };
// 純粋関数としてのReducer(副作用を持ってはならない)
function appReducer(state: State, action: Action): State {
switch (action.type) {
case ‘INCREMENT’:
return { …state, count: state.count + 1 };
case ‘DECREMENT’:
return { …state, count: state.count – 1 };
case ‘SET_USER’:
return { …state, user: { name: action.payload.name, isLoggedIn: true } };
case ‘LOGOUT’:
return { …state, user: null };
default:
// 型の網羅性チェック(Exhaustiveness Check)
const _: never = action;
return state;
}
}
このアプローチの美しさは、状態がどのように変化するのかの全貌がReducerという単一の関数を読むだけで完全に把握できる点にあります。非同期処理の競合や予期せぬステートの書き換えを防ぐための防壁として、これほど頼もしいものはありません。
—
`useContext` の最大の罠:パフォーマンス劣化のメカニズム
さて、Reducerを用意したら、それを `useContext` 経由でツリー全体に配りたくなります。しかし、ここでReactの根本的な仕様に起因する重大な問題が発生します。
「Contextの値が変化すると、そのContextを購読している(`useContext` を呼んでいる)すべてのコンポーネントが、たとえ参照しているプロパティが変更されていなくても、強制的に再レンダリングされる」
例えば、UIのカウンターの値(`state.count`)がインクリメントされただけで、ユーザー名(`state.user`)しか表示していないヘッダーコンポーネントまで再レンダリングが走ります。これがアプリ全体で何百個ものコンポーネントに波及したとき、メインスレッドは容易にブロックされ、Jank(カクつき)が発生します。
この問題を回避するため、私たちはアーキテクチャレベルで工夫を凝らす必要があります。
—
解決策:状態と更新関数の分離(Split Context Pattern)
このパフォーマンス問題を解決する最もエレガントかつ実用的なアプローチが、「状態(State)」と「ディスパッチ(Dispatch)」を別々のContextに分離するという設計パターンです。
なぜこれだけで劇的にパフォーマンスが改善されるか分かりますか?
Reactの `useDispatch`(実際には単なる `dispatch` 関数)は、Reactのライフサイクルを通じて参照が完全に安定(Referentially Stable)しています。つまり、`dispatch` を提供するContextは、値が一切変化しないため、これを購読しているコンポーネントは絶対に再レンダリングされません。
状態を参照したいコンポーネントだけがState用のContextを購読し、アクションを発火させたいだけのボタンなどはDispatch用のContextのみを購読するように分離するのです。
—
実装:堅牢なグローバル状態管理プロバイダー
それでは、実際のプロダクションコードに耐えうる実装を見ていきましょう。TypeScriptの恩恵を最大限に受け、型安全かつメモリ効率を考慮した設計にしています。
import React, { createContext, useContext, useReducer, useMemo, ReactNode } from ‘CACHED’;
// — 1. 型定義 —
interface State {
theme: ‘light’ | ‘dark’;
notificationsCount: number;
}
type Action =
| { type: ‘TOGGLE_THEME’ }
| { type: ‘SET_NOTIFICATIONS’; payload: number };
// — 2. Contextの作成(状態用とディスパッチ用を完全分離) —
const AppStateContext = createContext
const AppDispatchContext = createContext
// — 3. 初期状態 —
const initialState: State = {
theme: ‘light’,
notificationsCount: 0,
};
// — 4. Reducer —
function reducer(state: State, action: Action): State {
switch (action.type) {
case ‘TOGGLE_THEME’:
return { …state, theme: state.theme === ‘light’ ? ‘dark’ : ‘light’ };
case ‘SET_NOTIFICATIONS’:
return { …state, notificationsCount: action.payload };
default:
throw new Error(`Unhandled action type`);
}
}
// — 5. プロバイダーコンポーネント —
export const AppProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [state, dispatch] = useReducer(reducer, initialState);
// useMemoで状態オブジェクトの不要な生成を阻止
// これにより、stateのプロパティのいずれかが変化した時のみ参照が新しくなる
const memoizedState = useMemo(() => state, [state]);
// dispatchはReactによって参照が保証されているが、明示的にContextに渡す
return (
{children}
);
};
// — 6. 型安全なカスタムフック(ガーード付き) —
export function useAppState(): State {
const context = useContext(AppStateContext);
if (context === undefined) {
throw new Error(‘useAppState must be used within an AppProvider’);
}
return context;
}
export function useAppDispatch(): React.Dispatch
const context = useContext(AppDispatchContext);
if (context === undefined) {
throw new Error(‘useAppDispatch must be used within an AppProvider’);
}
return context;
}
—
さらに高みを目指す上級エンジニアへ:カスタムフックによるセレクターの模倣
ReduxやZustandには、ストアの一部だけを監視する「セレクター(Selector)」機能があります。上記の分離パターンでも、`state` 全体を購読しているため、`theme` が変わっただけで `notificationsCount` しか使っていないコンポーネントが再レンダリングされてしまいます。
これを完全に防ぎたい場合、カスタムフック内で `useMemo` を使うアプローチや、React 18以降の `useSyncExternalStore` の利用を検討すべきですが、Contextベースでも以下のような工夫で無駄な再レンダリングを抑制できます。
// 例:特定のスライスだけを監視するカスタムフック
export function useTheme(): [State[‘theme’], () => void] {
const state = useAppState();
const dispatch = useAppDispatch();
// テーマが変わったときだけに絞り込んだロジック、あるいはコンポーネント側でのメモ化
// 注意:Context自体の再レンダリングは防げませんが、コンポーネント内の計算コストや
// 子への伝播をReact.memoと組み合わせることで最適化可能です。
const toggleTheme = () => dispatch({ type: ‘TOGGLE_THEME’ });
return [state.theme, toggleTheme];
}
さらに厳密なパフォーマンスチューニングが必要な場合は、コンポーネントを `React.memo` でラップし、Propsとしてプリミティブな値や安定したコールバックのみを渡す鉄則を忘れないでください。
—
まとめ:バニラReactの限界を見極め、そして使い倒す
`useReducer` と `useContext` の組み合わせは、単なる「Reduxの代用品」ではありません。Reactというフレームワークの内部哲学である「単方向データフロー」と「予測可能な状態遷移」を最もピュアな形で具現化したアーキテクチャです。
外部ライブラリのバージョンアップに怯える必要もなく、Reactのコアアップデート(Concurrent Featuresなど)の恩恵を100%受けられるこの構成は、コードベースの寿命を確実に延ばします。
「何でもかんでもライブラリに頼る」という思考停止から脱却し、Reactのプリミティブを極限までチューニングしてエレガントなシステムを組み上げる。それこそが、真のフロントエンド・スペシャリストの醍醐味ではないでしょうか。
さあ、あなたの次のプロジェクトのコードベースから、不要な依存関係を削ぎ落としに行きましょう。

コメント