React

React状態管理実践ガイド|Context API・useReducerの活用

React 状態管理 Context API

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の具体例

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>;
}
prop drillingの問題点
1. 可読性の低下:中間コンポーネントに不要なpropsが大量に追加される
2. 保守性の悪化:propsの型やデータ構造を変更するとき、全階層の修正が必要
3. リファクタリングの困難さ:コンポーネントの階層変更が難しくなる

この問題を解決するのがContext APIです。Contextを使えば、コンポーネントツリーの途中を経由せず、必要なコンポーネントに直接データを届けられます。

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を作成

UserContext.js
import { createContext } from 'react';

// デフォルト値を指定してContextを作成
const UserContext = createContext(null);

export default UserContext;
createContextのデフォルト値
createContext(defaultValue) のデフォルト値は、Providerで囲まれていないコンポーネントがuseContextを呼んだときに使われます。
通常はProviderで囲むため nullundefined を渡し、Providerなしで使われた場合のエラー検出に活用します。

2. Providerで値を提供

App.jsx
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で値を取得

UserMenu.jsx
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を受け渡す必要がなくなりました

Layout.jsx — propsが不要になった
function Layout() {
  // userのpropsが消えた!
  return (
    <div>
      <Header />
      <Main />
    </div>
  );
}

function Header() {
  return <UserMenu />;
}

カスタムフックでContextをラップする(推奨)

UserContext.js — カスタムフック付き
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;
カスタムフックのメリット
1. Providerの外で使われた場合にエラーメッセージで原因を特定できる
2. 利用側で import { useUser } from './UserContext' だけで済む
3. Context の実装詳細を隠蔽できる

Step 3Contextの更新パターン

Contextの値を子コンポーネントから更新するには、更新関数もContextの値に含めて提供します。

stateと更新関数をセットで提供

ThemeContext.jsx
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;
}
ThemeToggle.jsx — 子コンポーネントからテーマを切り替え
import { useTheme } from './ThemeContext';

function ThemeToggle() {
  const { theme, toggleTheme } = useTheme();

  return (
    <button onClick={toggleTheme}>
      現在: {theme === 'light' ? 'ライト' : 'ダーク'}モード(切替)
    </button>
  );
}

パフォーマンスの注意点 — useMemoで値をメモ化

Providerのvalueにオブジェクトリテラルを直接渡すと、親コンポーネントが再レンダリングされるたびに新しいオブジェクトが生成され、Contextを使う全コンポーネントが不要に再レンダリングされます。

useMemoで再レンダリングを最適化
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>
  );
}
valueの参照が毎回変わる落とし穴
以下のように書くと、レンダリングのたびに新しいオブジェクトが作られます。
<MyContext.Provider value={{ user, setUser }}>
useMemo でラップするか、stateそのものを渡す形に変更しましょう。

Step 4複数のContextを組み合わせる

アプリケーションが大きくなると、ユーザー情報・テーマ・言語設定など、複数の種類の状態を管理する必要があります。1つのContextに全部入れるのではなく、役割ごとにContextを分割するのが推奨パターンです。

Contextを役割ごとに分割

AuthContext.jsx
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;
}
App.jsx — 複数のProviderをネスト
import { AuthProvider } from './AuthContext';
import { ThemeProvider } from './ThemeContext';

function App() {
  return (
    <AuthProvider>
      <ThemeProvider>
        <Layout />
      </ThemeProvider>
    </AuthProvider>
  );
}
Provider地獄を避けるテクニック
Providerが多くなると「Provider地獄」と呼ばれるネストの深さになりがちです。以下のようにユーティリティ関数でまとめることができます。
ComposeProviders.jsx — Providerをまとめるヘルパー
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の基本

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アプリのグローバル状態管理を構築しましょう。

TodoContext.jsx — useReducer + Context
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 };
TodoList.jsx — stateの読み取り
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>
  );
}
stateとdispatchを別Contextに分ける理由
dispatchだけを使うコンポーネント(入力フォームなど)は、stateが変化しても再レンダリングされなくなります。
読み取り専用と書き込み専用を分離することで、不要な再レンダリングを防げます。
  • reducer関数は純粋関数にする(副作用を含まない)
  • アクションの型は定数として管理する
  • stateとdispatchのContextを分離してパフォーマンスを最適化
  • カスタムフックでContextの利用をラップする

Step 6Context vs Redux vs Zustand — 使い分けガイド

状態管理の選択肢はContext API以外にも多数あります。プロジェクトの規模や要件に応じて適切なツールを選びましょう。

主要な状態管理ツールの比較

特性Context APIRedux(Redux Toolkit)Zustand
追加パッケージ不要(React標準)必要(@reduxjs/toolkit)必要(zustand)
バンドルサイズ0 KB約 11 KB約 1 KB
学習コスト低い中〜高い低い
ボイラープレート少ない中程度(RTKで改善)非常に少ない
DevToolsReact DevToolsRedux DevTools(強力)Redux DevToolsに対応
ミドルウェアなし豊富(thunk, saga等)対応
パフォーマンス大規模で不利セレクタで最適化可能自動で最適化
非同期処理自前で実装createAsyncThunkストア内で直接記述

Context APIが適するケース

Context APIが向いている状態の例
// テーマ(更新頻度: 低)
const ThemeContext = createContext('light');

// 認証情報(更新頻度: 低)
const AuthContext = createContext(null);

// ロケール(更新頻度: 低)
const LocaleContext = createContext('ja');

// モーダルの開閉(更新頻度: 低〜中)
const ModalContext = createContext({ isOpen: false });

Zustandが適するケース

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が適するケース

Redux Toolkit(RTK)のslice例
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はReact標準の状態共有手段として、prop drillingを解消する強力なツールです。
更新頻度が低い状態にはContext API、頻繁に更新される状態にはZustandやRedux Toolkitを使い分けましょう。
useReducerとContextを組み合わせれば、外部ライブラリなしでも構造的な状態管理が実現できます。