React状態管理実践ガイド
Context API・useReducerの活用
ReactのContext APIとuseReducerを使った状態管理を解説。prop drilling問題の解決から、Redux等との使い分けまで学べます。
こんな人向けの記事です
- prop drillingを解消したい
- Context APIの実践的な使い方を知りたい
- 状態管理ライブラリの選び方を理解したい
Step 1なぜ状態管理が必要か — prop drilling問題
Reactでは親コンポーネントから子コンポーネントへpropsを通じてデータを渡します。しかし、コンポーネントの階層が深くなると、中間コンポーネントが自分では使わないデータをただ受け渡すだけの「prop drilling(バケツリレー)」が発生します。
prop drillingの具体例
function App() {
const [user, setUser] = useState({ name: '田中太郎', role: 'admin' });
return <Layout user={user} />;
}
function Layout({ user }) {
// Layout自体はuserを使わないが、子に渡すために受け取る
return (
<div>
<Header user={user} />
<Main user={user} />
</div>
);
}
function Header({ user }) {
// Headerもuserを子に渡すだけ
return <UserMenu user={user} />;
}
function UserMenu({ user }) {
// ここでようやくuserを使う
return <span>{user.name}さん({user.role})</span>;
}
2. 保守性の悪化:propsの型やデータ構造を変更するとき、全階層の修正が必要
3. リファクタリングの困難さ:コンポーネントの階層変更が難しくなる
この問題を解決するのがContext APIです。Contextを使えば、コンポーネントツリーの途中を経由せず、必要なコンポーネントに直接データを届けられます。
// prop drilling(従来)
App -> Layout -> Header -> UserMenu
user user user user ← 全員がuserを受け渡す
// Context API(解決後)
App(Provider: user)
Layout ← userを知らなくてよい
Header ← userを知らなくてよい
UserMenu ← useContextでuserを直接取得
Step 2Context APIの基本(createContext, Provider, useContext)
Context APIは3つのステップで使います。1. Contextの作成、2. Providerで値を提供、3. useContextで値を取得です。
1. createContextでContextを作成
import { createContext } from 'react';
// デフォルト値を指定してContextを作成
const UserContext = createContext(null);
export default UserContext;
createContext(defaultValue) のデフォルト値は、Providerで囲まれていないコンポーネントがuseContextを呼んだときに使われます。通常はProviderで囲むため
null や undefined を渡し、Providerなしで使われた場合のエラー検出に活用します。
2. Providerで値を提供
import { useState } from 'react';
import UserContext from './UserContext';
import Layout from './Layout';
function App() {
const [user, setUser] = useState({ name: '田中太郎', role: 'admin' });
return (
// value属性で提供する値を指定
<UserContext.Provider value={user}>
<Layout />
</UserContext.Provider>
);
}
3. useContextで値を取得
import { useContext } from 'react';
import UserContext from './UserContext';
function UserMenu() {
// propsを受け取らず、直接Contextから取得
const user = useContext(UserContext);
if (!user) return null;
return <span>{user.name}さん({user.role})</span>;
}
中間コンポーネントはuserを受け渡す必要がなくなりました。
function Layout() {
// userのpropsが消えた!
return (
<div>
<Header />
<Main />
</div>
);
}
function Header() {
return <UserMenu />;
}
カスタムフックでContextをラップする(推奨)
import { createContext, useContext } from 'react';
const UserContext = createContext(null);
// カスタムフックを作成(推奨パターン)
export function useUser() {
const context = useContext(UserContext);
if (context === null) {
throw new Error('useUser must be used within a UserProvider');
}
return context;
}
export default UserContext;
2. 利用側で
import { useUser } from './UserContext' だけで済む3. Context の実装詳細を隠蔽できる
Step 3Contextの更新パターン
Contextの値を子コンポーネントから更新するには、更新関数もContextの値に含めて提供します。
stateと更新関数をセットで提供
import { createContext, useContext, useState } from 'react';
const ThemeContext = createContext(null);
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
const toggleTheme = () => {
setTheme(prev => (prev === 'light' ? 'dark' : 'light'));
};
// stateと更新関数をまとめてvalueに渡す
const value = { theme, toggleTheme };
return (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within ThemeProvider');
}
return context;
}
import { useTheme } from './ThemeContext';
function ThemeToggle() {
const { theme, toggleTheme } = useTheme();
return (
<button onClick={toggleTheme}>
現在: {theme === 'light' ? 'ライト' : 'ダーク'}モード(切替)
</button>
);
}
パフォーマンスの注意点 — useMemoで値をメモ化
Providerのvalueにオブジェクトリテラルを直接渡すと、親コンポーネントが再レンダリングされるたびに新しいオブジェクトが生成され、Contextを使う全コンポーネントが不要に再レンダリングされます。
import { createContext, useContext, useState, useMemo } from 'react';
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
const toggleTheme = () => {
setTheme(prev => (prev === 'light' ? 'dark' : 'light'));
};
// useMemoでvalueオブジェクトをメモ化
const value = useMemo(() => ({
theme,
toggleTheme,
}), [theme]); // themeが変わったときだけ新しいオブジェクトを作る
return (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
);
}
<MyContext.Provider value={{ user, setUser }}>useMemo でラップするか、stateそのものを渡す形に変更しましょう。
Step 4複数のContextを組み合わせる
アプリケーションが大きくなると、ユーザー情報・テーマ・言語設定など、複数の種類の状態を管理する必要があります。1つのContextに全部入れるのではなく、役割ごとにContextを分割するのが推奨パターンです。
Contextを役割ごとに分割
import { createContext, useContext, useState } from 'react';
const AuthContext = createContext(null);
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const login = (userData) => setUser(userData);
const logout = () => setUser(null);
return (
<AuthContext.Provider value={{ user, login, logout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) throw new Error('useAuth must be used within AuthProvider');
return context;
}
import { AuthProvider } from './AuthContext';
import { ThemeProvider } from './ThemeContext';
function App() {
return (
<AuthProvider>
<ThemeProvider>
<Layout />
</ThemeProvider>
</AuthProvider>
);
}
function ComposeProviders({ providers, children }) {
return providers.reduceRight(
(acc, Provider) => <Provider>{acc}</Provider>,
children
);
}
// 使い方
function App() {
return (
<ComposeProviders providers={[AuthProvider, ThemeProvider, LocaleProvider]}>
<Layout />
</ComposeProviders>
);
}
Context分割の判断基準
| 基準 | 分割する | まとめる |
|---|---|---|
| 更新頻度 | 異なる(テーマは稀、入力は頻繁) | 同じタイミングで更新される |
| 利用箇所 | 異なるコンポーネントで使用 | 常にセットで使われる |
| 関心の分離 | 独立した概念(認証とテーマ) | 密接に関連(ユーザー名とアバター) |
Step 5useReducerとContextの組み合わせ
状態の更新ロジックが複雑になってきたら、useStateの代わりにuseReducerを使うと、状態遷移を予測しやすくなります。Contextと組み合わせることで、ミニReduxのようなグローバル状態管理を実現できます。
useReducerの基本
import { useReducer } from 'react';
// 1. 初期状態
const initialState = { count: 0 };
// 2. reducer関数(純粋関数)
function reducer(state, action) {
switch (action.type) {
case 'INCREMENT':
return { count: state.count + 1 };
case 'DECREMENT':
return { count: state.count - 1 };
case 'RESET':
return initialState;
default:
throw new Error(`Unknown action: ${action.type}`);
}
}
function Counter() {
// 3. useReducerで状態とdispatch関数を取得
const [state, dispatch] = useReducer(reducer, initialState);
return (
<div>
<p>カウント: {state.count}</p>
<button onClick={() => dispatch({ type: 'INCREMENT' })}>+1</button>
<button onClick={() => dispatch({ type: 'DECREMENT' })}>-1</button>
<button onClick={() => dispatch({ type: 'RESET' })}>リセット</button>
</div>
);
}
実践:Todoアプリの状態管理
useReducerとContextを組み合わせて、Todoアプリのグローバル状態管理を構築しましょう。
import { createContext, useContext, useReducer } from 'react';
// アクションの型を定数化
const ACTIONS = {
ADD: 'ADD_TODO',
TOGGLE: 'TOGGLE_TODO',
DELETE: 'DELETE_TODO',
EDIT: 'EDIT_TODO',
};
const initialState = { todos: [], nextId: 1 };
function todoReducer(state, action) {
switch (action.type) {
case ACTIONS.ADD:
return {
...state,
todos: [
...state.todos,
{ id: state.nextId, text: action.payload, completed: false },
],
nextId: state.nextId + 1,
};
case ACTIONS.TOGGLE:
return {
...state,
todos: state.todos.map(todo =>
todo.id === action.payload
? { ...todo, completed: !todo.completed }
: todo
),
};
case ACTIONS.DELETE:
return {
...state,
todos: state.todos.filter(todo => todo.id !== action.payload),
};
case ACTIONS.EDIT:
return {
...state,
todos: state.todos.map(todo =>
todo.id === action.payload.id
? { ...todo, text: action.payload.text }
: todo
),
};
default:
throw new Error(`Unknown action: ${action.type}`);
}
}
// stateとdispatchを別のContextに分割(パフォーマンス最適化)
const TodoStateContext = createContext(null);
const TodoDispatchContext = createContext(null);
export function TodoProvider({ children }) {
const [state, dispatch] = useReducer(todoReducer, initialState);
return (
<TodoStateContext.Provider value={state}>
<TodoDispatchContext.Provider value={dispatch}>
{children}
</TodoDispatchContext.Provider>
</TodoStateContext.Provider>
);
}
export function useTodoState() {
const context = useContext(TodoStateContext);
if (!context) throw new Error('useTodoState must be used within TodoProvider');
return context;
}
export function useTodoDispatch() {
const context = useContext(TodoDispatchContext);
if (!context) throw new Error('useTodoDispatch must be used within TodoProvider');
return context;
}
export { ACTIONS };
import { useTodoState, useTodoDispatch, ACTIONS } from './TodoContext';
function TodoList() {
const { todos } = useTodoState();
const dispatch = useTodoDispatch();
return (
<ul>
{todos.map(todo => (
<li key={todo.id}>
<span
style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}
onClick={() => dispatch({ type: ACTIONS.TOGGLE, payload: todo.id })}
>
{todo.text}
</span>
<button onClick={() => dispatch({ type: ACTIONS.DELETE, payload: todo.id })}>
削除
</button>
</li>
))}
</ul>
);
}
読み取り専用と書き込み専用を分離することで、不要な再レンダリングを防げます。
- reducer関数は純粋関数にする(副作用を含まない)
- アクションの型は定数として管理する
- stateとdispatchのContextを分離してパフォーマンスを最適化
- カスタムフックでContextの利用をラップする
Step 6Context vs Redux vs Zustand — 使い分けガイド
状態管理の選択肢はContext API以外にも多数あります。プロジェクトの規模や要件に応じて適切なツールを選びましょう。
主要な状態管理ツールの比較
| 特性 | Context API | Redux(Redux Toolkit) | Zustand |
|---|---|---|---|
| 追加パッケージ | 不要(React標準) | 必要(@reduxjs/toolkit) | 必要(zustand) |
| バンドルサイズ | 0 KB | 約 11 KB | 約 1 KB |
| 学習コスト | 低い | 中〜高い | 低い |
| ボイラープレート | 少ない | 中程度(RTKで改善) | 非常に少ない |
| DevTools | React DevTools | Redux DevTools(強力) | Redux DevToolsに対応 |
| ミドルウェア | なし | 豊富(thunk, saga等) | 対応 |
| パフォーマンス | 大規模で不利 | セレクタで最適化可能 | 自動で最適化 |
| 非同期処理 | 自前で実装 | createAsyncThunk | ストア内で直接記述 |
Context APIが適するケース
// テーマ(更新頻度: 低)
const ThemeContext = createContext('light');
// 認証情報(更新頻度: 低)
const AuthContext = createContext(null);
// ロケール(更新頻度: 低)
const LocaleContext = createContext('ja');
// モーダルの開閉(更新頻度: 低〜中)
const ModalContext = createContext({ isOpen: false });
Zustandが適するケース
import { create } from 'zustand';
// Providerが不要、ストアをそのまま使える
const useCartStore = create((set) => ({
items: [],
addItem: (item) => set((state) => ({
items: [...state.items, item],
})),
removeItem: (id) => set((state) => ({
items: state.items.filter(item => item.id !== id),
})),
total: () => {
const { items } = useCartStore.getState();
return items.reduce((sum, item) => sum + item.price, 0);
},
}));
// コンポーネントで使用(Providerで囲む必要なし)
function CartCount() {
const count = useCartStore((state) => state.items.length);
return <span>カート: {count}個</span>;
}
Redux Toolkitが適するケース
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
// 非同期処理をthunkで定義
export const fetchProducts = createAsyncThunk(
'products/fetch',
async (_, { rejectWithValue }) => {
try {
const res = await fetch('/api/products');
return await res.json();
} catch (err) {
return rejectWithValue(err.message);
}
}
);
const productsSlice = createSlice({
name: 'products',
initialState: { items: [], loading: false, error: null },
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchProducts.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(fetchProducts.fulfilled, (state, action) => {
state.loading = false;
state.items = action.payload;
})
.addCase(fetchProducts.rejected, (state, action) => {
state.loading = false;
state.error = action.payload;
});
},
});
選定フローチャート
状態を複数コンポーネントで共有する?
├── No → useState / useReducer(ローカル状態で十分)
└── Yes → 更新頻度は高い?
├── No → Context API(テーマ、認証、設定など)
└── Yes → アプリの規模は?
├── 小〜中規模 → Zustand(シンプル、軽量)
└── 大規模 → Redux Toolkit
• 複雑な非同期フロー
• 複数チームで開発
• 厳格な状態管理が必要
- Context API:テーマ・認証など更新頻度の低いグローバル状態に最適
- Zustand:Providerが不要でシンプル。中規模アプリにおすすめ
- Redux Toolkit:大規模アプリ、複雑な非同期処理、厳格なデータフロー管理に
- 1つのアプリで複数を組み合わせるのも有効(Context + Zustandなど)
更新頻度が低い状態にはContext API、頻繁に更新される状態にはZustandやRedux Toolkitを使い分けましょう。
useReducerとContextを組み合わせれば、外部ライブラリなしでも構造的な状態管理が実現できます。