React Context API 実務での使い方|グローバル状態管理の実装パターン

React / Next.js

React Context API 実務での使い方|グローバル状態管理の実装パターン

Reactアプリケーションを開発していると、複数のコンポーネント間でデータを共有する必要が出てきます。Redux導入までは必要ないが、propsのバケツリレーを避けたい…そんな場面で活躍するのがContext APIです。本記事では、教科書的な説明ではなく、実務で実際に使われるパターンを中心に解説します。

React Context APIとは|簡易的な解説

Context APIは、Reactに組み込まれているグローバル状態管理の仕組みです。propsを使わずに、深くネストされたコンポーネント間でデータを直接受け渡しできます。

基本的な流れは以下の通りです:

  • Contextを作成する
  • Providerでコンポーネントをラップする
  • useContextフックで値を取得する

Redux などの外部ライブラリに比べて、セットアップが簡単で学習コストが低いのが特徴です。

業務でのユースケース|Context APIが活躍する場面

実務でContext APIが活躍するのは、以下のようなシーンです:

1. 認証情報の管理

ログイン状態、ユーザー情報、アクセストークンなど、アプリケーション全体で必要な認証情報は、Context APIで管理するのが一般的です。ログインしていないと一部の画面にアクセスできないといった制御も簡単になります。

2. テーマやダークモードの切り替え

ライトモード・ダークモード、言語設定など、ユーザーの環境設定をグローバルで管理します。localStorageと組み合わせて永続化することも多いです。

3. アラート・モーダルのグローバル管理

成功メッセージ、エラー通知、確認ダイアログなど、どのコンポーネントからでも呼び出せるようにします。

4. API呼び出し結果のキャッシング

API呼び出しの結果をContextに保存して、複数のコンポーネントから参照可能にします。

実装コード|実務で使う認証Context

ここから、実際の業務で使えるコードを示します。最も一般的な認証情報の管理を例に解説します。

ステップ1:Contextと型定義の作成

// contexts/AuthContext.tsx
import React, { createContext, useContext, useState, useCallback, ReactNode, useEffect } from 'react';

// 認証情報の型定義
interface User {
  id: string;
  email: string;
  name: string;
  role: 'admin' | 'user';
  avatar?: string;
}

interface AuthContextType {
  user: User | null;
  isLoading: boolean;
  error: string | null;
  login: (email: string, password: string) => Promise;
  logout: () => Promise;
  isAuthenticated: boolean;
  register: (email: string, password: string, name: string) => Promise;
}

// Contextの作成
const AuthContext = createContext(undefined);

// Providerコンポーネント
interface AuthProviderProps {
  children: ReactNode;
}

export const AuthProvider: React.FC = ({ children }) => {
  const [user, setUser] = useState(null);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState(null);

  // マウント時に保存されたセッション情報を復元
  useEffect(() => {
    const restoreSession = async () => {
      try {
        const token = localStorage.getItem('authToken');
        if (token) {
          // トークンの検証とユーザー情報の取得
          const response = await fetch('/api/auth/me', {
            headers: {
              Authorization: `Bearer ${token}`,
            },
          });
          
          if (response.ok) {
            const userData = await response.json();
            setUser(userData);
          } else {
            localStorage.removeItem('authToken');
          }
        }
      } catch (err) {
        console.error('セッション復元エラー:', err);
      } finally {
        setIsLoading(false);
      }
    };

    restoreSession();
  }, []);

  const login = useCallback(async (email: string, password: string) => {
    setIsLoading(true);
    setError(null);
    
    try {
      const response = await fetch('/api/auth/login', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ email, password }),
      });

      if (!response.ok) {
        throw new Error('ログインに失敗しました');
      }

      const data = await response.json();
      
      // トークンをlocalStorageに保存
      localStorage.setItem('authToken', data.token);
      setUser(data.user);
    } catch (err) {
      const message = err instanceof Error ? err.message : 'ログイン処理中にエラーが発生しました';
      setError(message);
      throw err;
    } finally {
      setIsLoading(false);
    }
  }, []);

  const logout = useCallback(async () => {
    setIsLoading(true);
    setError(null);
    
    try {
      const token = localStorage.getItem('authToken');
      
      // サーバー側のセッションを無効化
      if (token) {
        await fetch('/api/auth/logout', {
          method: 'POST',
          headers: {
            Authorization: `Bearer ${token}`,
          },
        });
      }

      localStorage.removeItem('authToken');
      setUser(null);
    } catch (err) {
      console.error('ログアウト処理エラー:', err);
    } finally {
      setIsLoading(false);
    }
  }, []);

  const register = useCallback(async (email: string, password: string, name: string) => {
    setIsLoading(true);
    setError(null);
    
    try {
      const response = await fetch('/api/auth/register', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ email, password, name }),
      });

      if (!response.ok) {
        throw new Error('登録に失敗しました');
      }

      const data = await response.json();
      localStorage.setItem('authToken', data.token);
      setUser(data.user);
    } catch (err) {
      const message = err instanceof Error ? err.message : '登録処理中にエラーが発生しました';
      setError(message);
      throw err;
    } finally {
      setIsLoading(false);
    }
  }, []);

  const value: AuthContextType = {
    user,
    isLoading,
    error,
    login,
    logout,
    register,
    isAuthenticated: user !== null,
  };

  return {children};
};

// カスタムフック
export const useAuth = () => {
  const context = useContext(AuthContext);
  
  if (!context) {
    throw new Error('useAuthはAuthProvider内で使用してください');
  }
  
  return context;
};

ステップ2:アプリケーションルートで使用

// App.tsx
import React from 'react';
import { AuthProvider } from './contexts/AuthContext';
import { Router } from './routes';

function App() {
  return (
    
      
    
  );
}

export default App;

ステップ3:コンポーネント内での使用

// components/LoginForm.tsx
import React, { useState } from 'react';
import { useAuth } from '../contexts/AuthContext';
import { useNavigate } from 'react-router-dom';

export const LoginForm: React.FC = () => {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const { login, isLoading, error } = useAuth();
  const navigate = useNavigate();

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    
    try {
      await login(email, password);
      navigate('/dashboard');
    } catch (err) {
      // エラーはContextのerrorで管理されている
    }
  };

  return (
    
setEmail(e.target.value)} placeholder=\"メールアドレス\" disabled={isLoading} /> setPassword(e.target.value)} placeholder=\"パスワード\" disabled={isLoading} /> {error &&

{error}

}
); };

ステップ4:ProtectedRoute(保護されたルート)の実装

// components/ProtectedRoute.tsx
import React from 'react';
import { Navigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';

interface ProtectedRouteProps {
  children: React.ReactNode;
  requiredRole?: 'admin' | 'user';
}

export const ProtectedRoute: React.FC = ({ 
  children, 
  requiredRole 
}) => {
  const { isAuthenticated, user, isLoading } = useAuth();

  if (isLoading) {
    return 
読み込み中...
; } if (!isAuthenticated) { return ; } if (requiredRole && user?.role !== requiredRole) { return ; } return <>{children}; };

よくある応用パターン|テーマ管理Context

認証Context以外に、実務でよく使われるテーマ管理の例も紹介します。

// contexts/ThemeContext.tsx
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';

type ThemeType = 'light' | 'dark';

interface ThemeContextType {
  theme: ThemeType;
  toggleTheme: () => void;
}

const ThemeContext = createContext(undefined);

export const ThemeProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
  const [theme, setTheme] = useState(() => {
    // ブラウザの設定またはlocalStorageから初期値を取得
    const savedTheme = localStorage.getItem('theme') as ThemeType | null;
    if (savedTheme) return savedTheme;
    
    return window.matchMedia('(prefers-color-scheme: dark)').matches 
      ? 'dark' 
      : 'light';
  });

  useEffect(() => {
    localStorage.setItem('theme', theme);
    
    // DOMに反映
    if (theme === 'dark') {
      document.documentElement.classList.add('dark');
    } else {
      document.documentElement.classList.remove('dark');
    }
  }, [theme]);

  const toggleTheme = () => {
    setTheme((prev) => (prev === 'light' ? 'dark' : 'light'));
  };

  return (
    
      {children}
    
  );
};

export const useTheme = () => {
  const context = useContext(ThemeContext);
  if (!context) {
    throw new Error('useThemeはThemeProvider内で使用してください');
  }
  return context;
};

複数のContextを組み合わせる|実務での構成

実務では複数のContextを組み合わせることがほとんどです。以下は複数のContextを統合した例です。

// contexts/index.tsx
import React, { ReactNode } from 'react';
import { AuthProvider } from './AuthContext';
import { ThemeProvider } from './ThemeContext';
import { NotificationProvider } from './NotificationContext';

interface ProvidersProps {
  children: ReactNode;
}

export const Providers: React.FC = ({ children }) => {
  return (
    
      
        
          {children}
        
      
    
  );
};

// App.tsx での使用
// import { Providers } from './contexts';
// 
// function App() {
//   return (
//     
//       
//     
//   );
// }

非同期処理とローディング状態の管理

実務では非同期処理が多いため、ローディング状態の管理は重要です。以下は、APIコール結果をキャッシュするContextの例です。

// contexts/DataContext.tsx
import React, { createContext, useContext, useState, useCallback, ReactNode } from 'react';

interface CacheEntry {
  data: T;
  timestamp: number;
  expiresIn: number;
}

interface DataContextType {
  fetchData: (url: string, options?: RequestInit) => Promise;
  clearCache: (url?: string) => void;
  isCaching: boolean;
}

const DataContext = createContext(undefined);
const CACHE_DURATION = 5 * 60 * 1000; // 5分

export const DataProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
  const [cache, setCache] = useState>>(new Map());
  const [isCaching, setIsCaching] = useState(false);

  const fetchData = useCallback(async (
    url: string, 
    options?: RequestInit
  ): Promise => {
    // キャッシュをチェック
    const cached = cache.get(url);
    if (cached && Date.now() - cached.timestamp < cached.expiresIn) {
      return cached.data;
    }

    setIsCaching(true);
    try {
      const token = localStorage.getItem('authToken');
      const headers: HeadersInit = {
        'Content-Type': 'application/json',
        ...options?.headers,
      };

      if (token) {
        headers.Authorization = `Bearer ${token}`;
      }

      const response = await fetch(url, {
        ...options,
        headers,
      });

      if (!response.ok) {
        throw new Error(`API Error: ${response.status}`);
      }

      const data = await response.json();
      
      // キャッシュに保存
      setCache((prev) => {
        const newCache = new Map(prev);
        newCache.set(url, {
          data,
          timestamp: Date.now(),
          expiresIn: CACHE_DURATION,
        });
        return newCache;
      });

      return data;
    } finally {
      setIsCaching(false);
    }
  }, [cache]);

  const clearCache = useCallback((url?: string) => {
    setCache((prev) => {
      const newCache = new Map(prev);
      if (url) {
        newCache.delete(url);
      } else {
        newCache.clear();
      }
      return newCache;
    });
  }, []);

  return (
    
      {children}
    
  );
};

export const useData = () => {
  const context = useContext(DataContext);
  if (!context) {
    throw new Error('useDataはDataProvider内で使用してください');
  }
  return context;
};

Context APIの注意点|実務で気をつけるべきこと

1. 過度な再レンダリング

Contextの値が更新されると、そのContextを使用するすべてのコンポーネントが再レンダリングされます。これはパフォーマンスの低下につながります。

// 悪い例:すべての値が一つのContextに入っている
const [state, setState] = useState({
  user: null,
  notifications: [],
  settings: {},
  theme: 'light',
});

// 良い例:関心事ごとにContextを分ける
// AuthContext, NotificationContext, SettingsContext, ThemeContextに分割

2. useContextが別のProviderの外で呼ばれるエラー

useContextはProviderの内側でのみ使用可能です。エラーハンドリングを忘れずに実装しましょう。

export const useAuth = () => {
  const context = useContext(AuthContext);
  
  if (!context) {
    throw new Error('useAuthはAuthProvider内で使用してください');
  }
  
  return context;
};

3. Context Selectorパターンの活用

大規模アプリケーションでは、必要な値のみを取得するSelectorパターンを採用します。

// useSelector的な使い方を実現する
export const useAuthUser = () => {
  const { user } = useAuth();
  return user;
};

export const useAuthIsAuthenticated = () => {
  const { isAuthenticated } = useAuth();
  return isAuthenticated;
};

4. 初期化の遅延

Contextの初期化処理が重い場合は、lazy initializerを使用します。

const [state, setState] = useState(() => {
  // 重い初期化処理
  const initialState = expensiveInitialization();
  return initialState;
});

5. メモリリーク対策

非同期処理を使用する場合は、コンポーネントのアンマウント時にクリーンアップを忘れずに。

useEffect(() => {
  let isMounted = true;

  const loadData = async () => {
    const data = await fetchData();
    if (isMounted) {
      setUser(data);
    }
  };

  loadData();

  return () => {
    isMounted = false; // クリーンアップ
  };
}, []);

Context APIでReduxは不要か?

よくある質問ですが、以下の基準で判断できます:

  • Context APIで十分:認証、テーマ、ユーザー設定など、シンプルなグローバル状態
  • Reduxを検討:複雑な状態更新ロジック、大量のアクション、デバッグ機能が必要、ミドルウェアが必要な場合

実務では、小~中規模プロジェクトではContext APIで十分なケースが多いです。

実務でのベストプラクティス

Context設計の基本原則

  • 関心事ごとに分ける(Single Responsibility Principle)
  • Contextの値は頻繁に変わるものと変わらないものを分離
  • useContextの呼び出しが深いネストを作らないようにする
  • DevTools対応を考慮する

テストについて

// テストでのProviderの使用方法
import { render, screen } from '@testing-library/react';
import { AuthProvider } from '../contexts/AuthContext';

test('ログインフォームのテスト', () => {
  render(
    
      
    
  );
  
  const emailInput = screen.getByPlaceholderText('メールアドレス');
  expect(emailInput).toBeInTheDocument();
});

まとめ

React Context APIは、適切に使用すればReduxなしでも十分な機能を提供します。実務での成功のポイントは以下の通りです:

  • 認証、テーマ、通知など関心事ごとにContextを分ける
  • useContextが常にProviderの内側で呼ばれるようにする
  • 不要な再レンダリングを避けるため、Selectorパターンを採用する
  • 非同期処理はしっかりローディング状態を管理する
  • localStorageなどの永続化を組み合わせる
  • TypeScriptの型定義を厳密にする

これらのパターンを押さえることで、小~中規模プロジェクトでは最適なグローバル状態管理が実現できます。プロジェクトの規模や複雑性に応じて、段階的にReduxへの移行を検討するのも良い戦略です。

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