React useContext 実務での使い方 | グローバル状態管理パターン解説

React / Next.js

React useContext 実務での使い方 | グローバル状態管理パターン解説

Reactアプリケーション開発において、複数のコンポーネント間でデータを共有する必要は頻繁に発生します。その際、何度も props をバケツリレーするのは開発効率を低下させます。こうした課題を解決するのが useContext フックです。本記事では、実務で実際に使用されるパターンを中心に、useContextの活用方法を詳しく解説します。

useContext とは | 簡易的な解説

useContextは、Reactが提供するHooksの一つで、Context APIを利用してコンポーネントツリー全体でデータを共有するためのメカニズムです。

基本的な仕組みは以下の通りです:

  • Context の作成:React.createContextでグローバル状態を定義
  • Provider による提供:Providerで囲まれた範囲内のコンポーネントがアクセス可能
  • useContext で消費:必要なコンポーネントで useContext を呼び出してデータ取得

props drilling(深い階層へのprops受け渡し)を避けられるため、コンポーネントの責任を明確に保つことができます。

業務でのユースケース | 実務で活躍する5つのシーン

1. ユーザー認証情報の管理

ほぼすべてのWebアプリケーションで必要となるユーザー認証。ログイン状態やユーザー情報を全アプリケーション内で保有する必要があります。

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

ユーザー設定に応じてアプリケーション全体の色合いを変更する機能。Providerで囲むことで、全コンポーネントから簡単にアクセス可能です。

3. 言語設定(i18n)

多言語対応アプリケーションでは、選択された言語情報をグローバルに保有し、任意のコンポーネントから翻訳文を取得する必要があります。

4. モーダル・トースト通知の中央管理

複数箇所から呼び出される可能性のあるUI要素を、一元管理することで保守性を向上させます。

5. フォーム状態の複雑な管理

マルチステップフォームやウィザード形式では、複数ページにまたがる入力値をグローバルに保有する必要があります。

実装コード | 実務レベルの完全な実装例

例1:ユーザー認証コンテキストの実装

最も一般的な用途である、ユーザー認証情報の管理を実装してみましょう。

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

// ユーザー型定義
interface User {
  id: string;
  email: string;
  name: string;
  avatar?: string;
  role: 'admin' | 'user' | 'guest';
}

// Contextの型定義
interface AuthContextType {
  user: User | null;
  isLoading: boolean;
  error: string | null;
  login: (email: string, password: string) => Promise;
  logout: () => void;
  updateUser: (user: Partial) => void;
  isAuthenticated: boolean;
}

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

// Provider コンポーネント
export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
  const [user, setUser] = useState(null);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState(null);

  const login = useCallback(async (email: string, password: string) => {
    setIsLoading(true);
    setError(null);
    try {
      // 実務では、ここで実際のAPI呼び出しを行う
      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();
      setUser(data.user);
      // トークンをlocalStorageに保存(CSRF対策は別途実装)
      localStorage.setItem('authToken', data.token);
    } catch (err) {
      setError(err instanceof Error ? err.message : 'エラーが発生しました');
      throw err;
    } finally {
      setIsLoading(false);
    }
  }, []);

  const logout = useCallback(() => {
    setUser(null);
    localStorage.removeItem('authToken');
    // 実務では、サーバー側のセッションも削除
    fetch('/api/auth/logout', { method: 'POST' }).catch(console.error);
  }, []);

  const updateUser = useCallback((updates: Partial) => {
    setUser(prev => prev ? { ...prev, ...updates } : null);
  }, []);

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

  return {children};
};

// カスタムフック:useAuthの提供
export const useAuth = () => {
  const context = useContext(AuthContext);
  if (context === undefined) {
    throw new Error('useAuth must be used within AuthProvider');
  }
  return context;
};

例2:useAuthの実装コンポーネントでの使用

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

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

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    try {
      await login(email, password);
      // ログイン成功後のリダイレクトはルーティング層で処理
    } catch (err) {
      // エラーハンドリング(errorはContextで管理)
    }
  };

  return (
    
setEmail(e.target.value)} disabled={isLoading} placeholder=\"メールアドレス\" /> setPassword(e.target.value)} disabled={isLoading} placeholder=\"パスワード\" /> {error &&
{error}
}
); }; // components/UserProfile.tsx export const UserProfile: React.FC = () => { const { user, logout } = useAuth(); if (!user) { return
ログインしてください
; } return (

{user.name}

{user.email}

{user.avatar && {user.name}}
); };

例3:テーマ管理コンテキストの実装

続いて、ダークモード切り替え機能の実装を見てみましょう。

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

type Theme = 'light' | 'dark' | 'system';

interface ThemeContextType {
  theme: Theme;
  effectiveTheme: 'light' | 'dark';
  setTheme: (theme: Theme) => void;
}

const ThemeContext = createContext(undefined);

export const ThemeProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
  const [theme, setThemeState] = useState(() => {
    // ローカルストレージから取得、なければsystemを使用
    return (localStorage.getItem('theme') as Theme) || 'system';
  });

  const [effectiveTheme, setEffectiveTheme] = useState<'light' | 'dark'>('light');

  useEffect(() => {
    // システム設定の監視
    const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
    
    const updateTheme = () => {
      let current: 'light' | 'dark' = 'light';
      
      if (theme === 'system') {
        current = mediaQuery.matches ? 'dark' : 'light';
      } else {
        current = theme;
      }
      
      setEffectiveTheme(current);
      document.documentElement.setAttribute('data-theme', current);
    };

    updateTheme();
    mediaQuery.addEventListener('change', updateTheme);

    return () => mediaQuery.removeEventListener('change', updateTheme);
  }, [theme]);

  const setTheme = (newTheme: Theme) => {
    setThemeState(newTheme);
    localStorage.setItem('theme', newTheme);
  };

  return (
    
      {children}
    
  );
};

export const useTheme = () => {
  const context = useContext(ThemeContext);
  if (context === undefined) {
    throw new Error('useTheme must be used within ThemeProvider');
  }
  return context;
};

例4:トースト通知管理の実装

複数箇所から呼び出される通知機能も、実務ではContextで一元管理します。

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

export type ToastType = 'success' | 'error' | 'info' | 'warning';

interface Toast {
  id: string;
  message: string;
  type: ToastType;
  duration?: number;
}

interface ToastContextType {
  toasts: Toast[];
  showToast: (message: string, type: ToastType, duration?: number) => void;
  removeToast: (id: string) => void;
}

const ToastContext = createContext(undefined);

export const ToastProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
  const [toasts, setToasts] = useState([]);

  const showToast = useCallback((message: string, type: ToastType, duration = 3000) => {
    const id = Math.random().toString(36).substr(2, 9);
    const toast: Toast = { id, message, type, duration };
    
    setToasts(prev => [...prev, toast]);

    if (duration) {
      setTimeout(() => removeToast(id), duration);
    }
  }, []);

  const removeToast = useCallback((id: string) => {
    setToasts(prev => prev.filter(t => t.id !== id));
  }, []);

  return (
    
      {children}
    
  );
};

export const useToast = () => {
  const context = useContext(ToastContext);
  if (context === undefined) {
    throw new Error('useToast must be used within ToastProvider');
  }
  return context;
};

例5:トースト通知の表示コンポーネント

// components/ToastContainer.tsx
import React from 'react';
import { useToast } from '../contexts/ToastContext';

export const ToastContainer: React.FC = () => {
  const { toasts, removeToast } = useToast();

  return (
    
{toasts.map(toast => (

{toast.message}

))}
); }; // 実際の使用例 export const DataDeleteButton: React.FC<{ id: string }> = ({ id }) => { const { showToast } = useToast(); const handleDelete = async () => { try { await fetch(`/api/data/${id}`, { method: 'DELETE' }); showToast('削除が完了しました', 'success'); } catch (err) { showToast('削除に失敗しました', 'error'); } }; return ; };

よくある応用パターン | 実務でよく見かける組み合わせ

パターン1:複数Contextの組み合わせ

実務では複数のContextを組み合わせることが一般的です。以下のように専用フックでまとめます。

// hooks/useAppContext.ts
import { useAuth } from '../contexts/AuthContext';
import { useTheme } from '../contexts/ThemeContext';
import { useToast } from '../contexts/ToastContext';

export const useAppContext = () => {
  return {
    auth: useAuth(),
    theme: useTheme(),
    toast: useToast(),
  };
};

// 使用例
const MyComponent: React.FC = () => {
  const { auth, theme, toast } = useAppContext();
  
  return 
{/* コンポーネントコード */}
; };

パターン2:useReducerを組み合わせた複雑な状態管理

// contexts/FormContext.tsx
import React, { createContext, useContext, useReducer, ReactNode } from 'react';

interface FormData {
  step: number;
  firstName: string;
  lastName: string;
  email: string;
  phone: string;
  address: string;
}

type FormAction =
  | { type: 'SET_STEP'; payload: number }
  | { type: 'UPDATE_FIELD'; payload: { field: keyof FormData; value: string } }
  | { type: 'RESET' };

const initialState: FormData = {
  step: 1,
  firstName: '',
  lastName: '',
  email: '',
  phone: '',
  address: '',
};

const formReducer = (state: FormData, action: FormAction): FormData => {
  switch (action.type) {
    case 'SET_STEP':
      return { ...state, step: action.payload };
    case 'UPDATE_FIELD':
      return { ...state, [action.payload.field]: action.payload.value };
    case 'RESET':
      return initialState;
    default:
      return state;
  }
};

interface FormContextType {
  state: FormData;
  dispatch: React.Dispatch;
}

const FormContext = createContext(undefined);

export const FormProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
  const [state, dispatch] = useReducer(formReducer, initialState);

  return (
    
      {children}
    
  );
};

export const useForm = () => {
  const context = useContext(FormContext);
  if (context === undefined) {
    throw new Error('useForm must be used within FormProvider');
  }
  return context;
};

パターン3:初期化と同期化

アプリケーション起動時にサーバーから必要なデータを取得し、Contextに同期させるパターンです。

// hooks/useInitializeApp.ts
import { useEffect } from 'react';
import { useAuth } from '../contexts/AuthContext';

export const useInitializeApp = () => {
  const { user } = useAuth();

  useEffect(() => {
    const initializeApp = async () => {
      try {
        // トークンが存在する場合、ユーザー情報を復元
        const token = localStorage.getItem('authToken');
        if (token && !user) {
          const response = await fetch('/api/auth/me', {
            headers: { Authorization: `Bearer ${token}` },
          });
          
          if (response.ok) {
            const userData = await response.json();
            // useAuthの updateUser を使用してユーザー情報を更新
          }
        }
      } catch (error) {
        console.error('App initialization failed:', error);
      }
    };

    initializeApp();
  }, []);
};

// App.tsx での使用
import { useInitializeApp } from './hooks/useInitializeApp';

export const App: React.FC = () => {
  useInitializeApp();

  return (
    
      {/* ルート定義 */}
    
  );
};

注意点とベストプラクティス

1. 過度な使用を避ける

すべての状態をContextで管理するのは避けましょう。useContextは再レンダリング性能に影響します。以下の判断基準を参考にしてください:

  • Context向き:ユーザー認証、言語設定、テーマなど変更頻度が低い情報
  • Props向き:コンポーネント固有の状態や頻繁に変わる状態
  • Redux/Zustand向き:複雑な状態遷移が多い大規模アプリケーション

2. Context の分割

複数の責務を持つ大きなContextより、責務を分割した複数の小さいContextが推奨されます。

// 良い例:責務が明確に分かれている
<AuthProvider>
  <ThemeProvider>
    <NotificationProvider>
      <App />
    </NotificationProvider>
  </ThemeProvider>
</AuthProvider>

// 避けるべき例:1つの巨大なContext
<AppProvider>
  <App />
</AppProvider>

3. 型安全性の確保

TypeScriptを使用する場合、Contextの型定義を厳密に行うことで、開発体験と保守性が向上します。

// 良い例:型定義が明確
interface AuthContextType {
  user: User | null;
  isLoading: boolean;
  login: (email: string, password: string) => Promise;
  // ...
}

// 避けるべき例:型定義が不十分
const AuthContext = createContext(undefined);

4. useCallbackによる最適化

Context内の関数はuseCallbackでメモ化し、不要な再レンダリングを防ぎます。

const login = useCallback(async (email: string, password: string) => {
  // ログイン処理
}, []); // 依存配列に注意

5. デフォルト値の提供

useContextを使用するカスタムフックでは、Providerの外で使用された場合のエラーハンドリングを忘れずに。

export const useAuth = () => {
  const context = useContext(AuthContext);
  if (context === undefined) {
    throw new Error('useAuth must be used within AuthProvider');
    // または開発環境でのみ警告を出す
  }
  return context;
};

6. パフォーマンスを考慮した値の設計

Context の value は毎回新しいオブジェクトが作成されないようにuseMemoでメモ化します。

const value: AuthContextType = useMemo(() => ({
  user,
  isLoading,
  error,
  login,
  logout,
  updateUser,
  isAuthenticated: user !== null,
}), [user, isLoading, error, login, logout, updateUser]);

return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;

実践的な注意点:実務での落とし穴

複雑な派生状態の管理

Contextに複雑な計算結果を含めるのは避けましょう。カスタムフック内で計算するほうが効率的です。

// 避けるべき例:Contextで計算結果を保有
const AuthProvider = ({ children }) => {
  const [users, setUsers] = useState([]);
  const [currentUserId, setCurrentUserId] = useState('');
  // 毎回新しいオブジェクトが生成される
  const currentUser = users.find(u => u.id === currentUserId);
  const value = { users, currentUser, currentUserId };
  // ...
};

// 推奨:カスタムフックで計算
export const useCurrentUser = () => {
  const { users, currentUserId } = useAuth();
  return useMemo(() => users.find(u => u.id === currentUserId), [users, currentUserId]);
};

セキュリティの考慮

Contextに保存されたデータはクライアント側で参照可能です。認証トークンや機密情報の管理に注意してください。

// 避けるべき例:Contextにトークンを保存
const value = {
  user,
  token, // ❌ クライアント側で参照可能
};

// 推奨:トークンはhttpOnly Cookieまたは専用管理方式を使用
// Contextには必要な情報のみを保有
const value = {
  user,
  isAuthenticated: !!token,
};

まとめ

React useContext は、適切に活用することで開発効率を大幅に向上させることができます。実務での主なポイントは以下の通りです:

  • 適切なユースケース選択:全ての状態ではなく、グローバルに必要な情報に限定
  • 型安全性:TypeScriptで厳密に型定義し、開発体験を向上
  • パフォーマンス:useMemo、useCallback を活用して無駄な再レンダリングを防止
  • 責務の分離:複数の小さいContextに分割し、保守性を確保
  • エラーハンドリング:Providerの外での使用時のエラーメッセージを明確に
  • セキュリティ:機密情報の管理方法に注意

これらのポイントを押さえることで、スケーラブルで保守性の高い Reactアプリケーションを構築できます。プロジェクトの規模が拡大し、状態管理が複雑になった場合は、Redux や Zustand などのライブラリへの移行を検討するのも良い選択肢です。

実務では「今何が必要か」を見極め、適切なツールを選択することが重要です。useContext は多くのアプリケーションで十分な機能を提供し、シンプルで理解しやすいため、まずはこれで始めることをお勧めします。

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