React エラーハンドリング実務パターン|業務で実装すべき8つの実装方法

React / Next.js

React エラーハンドリング実務パターン|業務で実装すべき8つの実装方法

Reactアプリケーション開発において、エラーハンドリングは機能追加と同等かそれ以上に重要です。しかし実務では、テストコードばかり書いていてエラーハンドリングの実装がおろそかになるチーム、あるいは例外処理の責任が曖昧になっているプロジェクトが多く見られます。本記事では、実際の業務で使える、より実践的なReactのエラーハンドリングパターンを紹介します。

Reactエラーハンドリングの基礎概念

Reactでのエラーハンドリングは、大きく3つのカテゴリに分かれます:

  • レンダリングエラー(rendering errors)- コンポーネント描画時に発生するエラー
  • イベントハンドラーエラー(event handler errors)- ユーザーアクション時のエラー
  • 非同期エラー(async errors)- API呼び出しやデータ処理時のエラー

各カテゴリで対応方法が異なるため、まずそれぞれの特性を理解することが必要です。

業務でよく発生するエラーケース

実務で実際に遭遇するエラーシーンを整理すると以下のようになります:

  • APIタイムアウトやネットワーク接続エラー
  • 不正なAPIレスポンス構造のデータ受け取り
  • メモリリークを引き起こす非同期処理
  • コンポーネント内での予期しないnull/undefined参照
  • サードパーティライブラリの互換性エラー
  • 本番環境でのみ発生する再現困難なエラー

特に本番環境でのエラーは顧客体験に直結するため、事前の予防と事後の追跡可能性が重要です。

実装パターン1:Error Boundaryの正しい使い方

Error Boundaryはクラスコンポーネントで実装され、子コンポーネントのレンダリングエラーをキャッチします。実務では、単純なエラー表示だけでなく、エラー情報の記録とリカバリーロジックを組み込みます。

import React, { ReactNode, ErrorInfo } from 'react';\n\ninterface Props {\n  children: ReactNode;\n  fallback?: (error: Error, retry: () => void) => ReactNode;\n  onError?: (error: Error, errorInfo: ErrorInfo) => void;\n}\n\ninterface State {\n  hasError: boolean;\n  error: Error | null;\n}\n\nclass ErrorBoundary extends React.Component<Props, State> {\n  constructor(props: Props) {\n    super(props);\n    this.state = { hasError: false, error: null };\n  }\n\n  static getDerivedStateFromError(error: Error): State {\n    return { hasError: true, error };\n  }\n\n  componentDidCatch(error: Error, errorInfo: ErrorInfo) {\n    // ロギングサービスに送信\n    this.logErrorToService(error, errorInfo);\n    \n    if (this.props.onError) {\n      this.props.onError(error, errorInfo);\n    }\n  }\n\n  logErrorToService = (error: Error, errorInfo: ErrorInfo) => {\n    const errorPayload = {\n      message: error.message,\n      stack: error.stack,\n      componentStack: errorInfo.componentStack,\n      timestamp: new Date().toISOString(),\n      userAgent: navigator.userAgent,\n      url: window.location.href,\n    };\n\n    // Sentry, LogRocket等のサービスに送信\n    console.error('Error caught by boundary:', errorPayload);\n  };\n\n  handleReset = () => {\n    this.setState({ hasError: false, error: null });\n  };\n\n  render() {\n    if (this.state.hasError && this.state.error) {\n      return (\n        this.props.fallback ? (\n          this.props.fallback(this.state.error, this.handleReset)\n        ) : (\n          
\n

エラーが発生しました

\n

{this.state.error.message}

\n \n
\n )\n );\n }\n\n return this.props.children;\n }\n}\n\nexport default ErrorBoundary;

使用例:

<ErrorBoundary\n  onError={(error, info) => {\n    // エラーをGoogle Analyticsに送信\n    gtag('event', 'exception', {\n      description: error.message,\n      fatal: false,\n    });\n  }}\n  fallback={(error, retry) => (\n    <div className=\"error-container\">\n      <h1>画面表示エラー</h1>\n      <p>申し訳ありません。画面の読み込みに失敗しました。</p>\n      <button onClick={retry}>もう一度試す</button>\n    </div>\n  )}\n>\n  <YourComponent />\n</ErrorBoundary>

実装パターン2:カスタムフックによるエラーハンドリング

モダンな業務開発では関数コンポーネントが主流です。useErrorHandler互換のカスタムフックを作成することで、エラーハンドリングロジックを再利用可能にします。

import { useState, useCallback, useRef } from 'react';\n\ninterface UseErrorHandlerReturn {\n  error: Error | null;\n  hasError: boolean;\n  clearError: () => void;\n  handleError: (error: Error | string) => void;\n  resetError: () => void;\n}\n\nfunction useErrorHandler(): UseErrorHandlerReturn {\n  const [error, setError] = useState<Error | null>(null);\n  const retryCountRef = useRef(0);\n  const MAX_RETRIES = 3;\n\n  const handleError = useCallback((error: Error | string) => {\n    const errorObj = typeof error === 'string' \n      ? new Error(error) \n      : error;\n    \n    setError(errorObj);\n    \n    // エラーを外部サービスに記録\n    logErrorMetrics(errorObj);\n  }, []);\n\n  const clearError = useCallback(() => {\n    setError(null);\n    retryCountRef.current = 0;\n  }, []);\n\n  const resetError = useCallback(() => {\n    setError(null);\n  }, []);\n\n  return {\n    error,\n    hasError: error !== null,\n    clearError,\n    handleError,\n    resetError,\n  };\n}\n\nfunction logErrorMetrics(error: Error) {\n  // 実務では以下のような情報を記録します\n  const metadata = {\n    timestamp: new Date().toISOString(),\n    errorMessage: error.message,\n    errorStack: error.stack,\n    pathname: window.location.pathname,\n    userAgent: navigator.userAgent,\n  };\n  \n  // 本番環境でのみ外部サービスに送信\n  if (process.env.NODE_ENV === 'production') {\n    fetch('/api/logs/errors', {\n      method: 'POST',\n      headers: { 'Content-Type': 'application/json' },\n      body: JSON.stringify(metadata),\n    }).catch(err => console.error('Failed to log error:', err));\n  }\n}\n\nexport default useErrorHandler;

実装パターン3:APIエラーハンドリングの実務パターン

実務でのAPI呼び出しエラーは、単なるネットワークエラーだけでなく、ビジネスロジックエラーも含みます。以下のコードは、実務で本当に使えるAPI呼び出しフックです。

import { useState, useCallback } from 'react';\n\ninterface ApiError {\n  statusCode: number;\n  message: string;\n  details?: Record<string, any>;\n  timestamp: string;\n}\n\ninterface UseAsyncOptions {\n  onError?: (error: ApiError) => void;\n  onSuccess?: (data: any) => void;\n  retryCount?: number;\n  retryDelay?: number;\n}\n\nfunction useAsync<T>(\n  asyncFunction: () => Promise<T>,\n  options: UseAsyncOptions = {}\n) {\n  const [data, setData] = useState<T | null>(null);\n  const [loading, setLoading] = useState(false);\n  const [error, setError] = useState<ApiError | null>(null);\n  const [retryCount, setRetryCount] = useState(0);\n\n  const { \n    onError, \n    onSuccess, \n    retryCount: maxRetries = 3,\n    retryDelay = 1000,\n  } = options;\n\n  const execute = useCallback(async () => {\n    setLoading(true);\n    setError(null);\n\n    try {\n      const result = await asyncFunction();\n      setData(result);\n      onSuccess?.(result);\n    } catch (err) {\n      const apiError = normalizeError(err);\n      setError(apiError);\n      \n      // リトライロジック\n      if (retryCount < maxRetries && isRetryableError(apiError)) {\n        setTimeout(() => {\n          setRetryCount(prev => prev + 1);\n          execute();\n        }, retryDelay * Math.pow(2, retryCount)); // exponential backoff\n      } else {\n        onError?.(apiError);\n      }\n    } finally {\n      setLoading(false);\n    }\n  }, [asyncFunction, onError, onSuccess, retryCount, maxRetries, retryDelay]);\n\n  return { data, loading, error, execute };\n}\n\nfunction normalizeError(err: any): ApiError {\n  // Fetchエラーの正規化\n  if (err instanceof Response) {\n    return {\n      statusCode: err.status,\n      message: err.statusText || 'Unknown error',\n      timestamp: new Date().toISOString(),\n    };\n  }\n\n  // 既にApiError形式\n  if (err && typeof err === 'object' && 'statusCode' in err) {\n    return err as ApiError;\n  }\n\n  // Error型やその他\n  return {\n    statusCode: 500,\n    message: err instanceof Error ? err.message : 'Unknown error',\n    timestamp: new Date().toISOString(),\n  };\n}\n\nfunction isRetryableError(error: ApiError): boolean {\n  // リトライ対象のステータスコード\n  const retryableStatusCodes = [408, 429, 500, 502, 503, 504];\n  return retryableStatusCodes.includes(error.statusCode);\n}\n\nexport { useAsync, ApiError };

使用例:

function UserProfile({ userId }: { userId: string }) {\n  const fetchUser = useCallback(async () => {\n    const response = await fetch(`/api/users/${userId}`);\n    if (!response.ok) {\n      throw response;\n    }\n    return response.json();\n  }, [userId]);\n\n  const { data, loading, error, execute } = useAsync(fetchUser, {\n    onSuccess: (data) => {\n      console.log('User loaded successfully:', data);\n    },\n    onError: (error) => {\n      toast.error(`ユーザー情報読み込みエラー: ${error.message}`);\n    },\n    retryCount: 3,\n    retryDelay: 1000,\n  });\n\n  useEffect(() => {\n    execute();\n  }, [execute]);\n\n  if (loading) return 
読み込み中...
;\n if (error) return
エラー: {error.message}
;\n if (!data) return null;\n\n return (\n
\n

{data.name}

\n

{data.email}

\n
\n );\n}

実装パターン4:フォーム送信時のエラーハンドリング

フォーム送信は実務で最も頻繁に発生するエラーケースです。バリデーションエラーとサーバーエラーの両方に対応する必要があります。

import { useState } from 'react';\n\ninterface FormErrors {\n  [key: string]: string;\n}\n\ninterface FormState {\n  email: string;\n  password: string;\n  agreeTerms: boolean;\n}\n\nfunction LoginForm() {\n  const [formData, setFormData] = useState<FormState>({\n    email: '',\n    password: '',\n    agreeTerms: false,\n  });\n  const [errors, setErrors] = useState<FormErrors>({});\n  const [submitting, setSubmitting] = useState(false);\n  const [submitError, setSubmitError] = useState<string | null>(null);\n\n  const validateForm = (): boolean => {\n    const newErrors: FormErrors = {};\n\n    // メールバリデーション\n    if (!formData.email) {\n      newErrors.email = 'メールアドレスは必須です';\n    } else if (!/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(formData.email)) {\n      newErrors.email = '有効なメールアドレスを入力してください';\n    }\n\n    // パスワードバリデーション\n    if (!formData.password) {\n      newErrors.password = 'パスワードは必須です';\n    } else if (formData.password.length < 8) {\n      newErrors.password = 'パスワードは8文字以上である必要があります';\n    }\n\n    // 利用規約チェック\n    if (!formData.agreeTerms) {\n      newErrors.agreeTerms = '利用規約に同意する必要があります';\n    }\n\n    setErrors(newErrors);\n    return Object.keys(newErrors).length === 0;\n  };\n\n  const handleSubmit = async (e: React.FormEvent) => {\n    e.preventDefault();\n    setSubmitError(null);\n\n    // クライアント側バリデーション\n    if (!validateForm()) {\n      setSubmitError('入力内容を確認してください');\n      return;\n    }\n\n    setSubmitting(true);\n\n    try {\n      const response = await fetch('/api/login', {\n        method: 'POST',\n        headers: { 'Content-Type': 'application/json' },\n        body: JSON.stringify(formData),\n      });\n\n      if (!response.ok) {\n        const errorData = await response.json();\n        \n        // サーバーがフィールドレベルのエラーを返した場合\n        if (response.status === 422 && errorData.fieldErrors) {\n          setErrors(errorData.fieldErrors);\n          setSubmitError('入力内容をご確認ください');\n        } else {\n          // その他のエラー\n          setSubmitError(\n            errorData.message || 'ログインに失敗しました。もう一度お試しください。'\n          );\n        }\n        return;\n      }\n\n      const data = await response.json();\n      // ログイン成功時の処理\n      localStorage.setItem('authToken', data.token);\n      window.location.href = '/dashboard';\n\n    } catch (error) {\n      if (error instanceof TypeError) {\n        setSubmitError('ネットワーク接続をご確認ください');\n      } else {\n        setSubmitError('予期しないエラーが発生しました');\n      }\n    } finally {\n      setSubmitting(false);\n    }\n  };\n\n  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {\n    const { name, value, type, checked } = e.target;\n    setFormData(prev => ({\n      ...prev,\n      [name]: type === 'checkbox' ? checked : value,\n    }));\n    // フィールドのエラーをクリア\n    if (errors[name]) {\n      setErrors(prev => {\n        const newErrors = { ...prev };\n        delete newErrors[name];\n        return newErrors;\n      });\n    }\n  };\n\n  return (\n    <form onSubmit={handleSubmit}>\n      {submitError && (\n        <div style={{ color: '#f5222d', marginBottom: '16px' }}>\n          {submitError}\n        </div>\n      )}\n\n      <div>\n        <label htmlFor=\"email\">メールアドレス</label>\n        <input\n          id=\"email\"\n          name=\"email\"\n          type=\"email\"\n          value={formData.email}\n          onChange={handleChange}\n          aria-invalid={!!errors.email}\n        />\n        {errors.email && (\n          <span style={{ color: '#f5222d', fontSize: '12px' }}>\n            {errors.email}\n          </span>\n        )}\n      </div>\n\n      <div>\n        <label htmlFor=\"password\">パスワード</label>\n        <input\n          id=\"password\"\n          name=\"password\"\n          type=\"password\"\n          value={formData.password}\n          onChange={handleChange}\n          aria-invalid={!!errors.password}\n        />\n        {errors.password && (\n          <span style={{ color: '#f5222d', fontSize: '12px' }}>\n            {errors.password}\n          </span>\n        )}\n      </div>\n\n      <div>\n        <label>\n          <input\n            name=\"agreeTerms\"\n            type=\"checkbox\"\n            checked={formData.agreeTerms}\n            onChange={handleChange}\n          />\n          利用規約に同意する\n        </label>\n        {errors.agreeTerms && (\n          <span style={{ color: '#f5222d', fontSize: '12px' }}>\n            {errors.agreeTerms}\n          </span>\n        )}\n      </div>\n\n      <button type=\"submit\" disabled={submitting}>\n        {submitting ? 'ログイン中...' : 'ログイン'}\n      </button>\n    </form>\n  );\n}\n\nexport default LoginForm;

実装パターン5:タイムアウト処理とキャンセル機構

長時間のAPI呼び出しはユーザー体験を悪化させます。実務ではタイムアウト処理を必須としています。

interface FetchWithTimeoutOptions {\n  timeout?: number;\n  signal?: AbortSignal;\n}\n\nclass TimeoutError extends Error {\n  constructor(message = 'Request timeout') {\n    super(message);\n    this.name = 'TimeoutError';\n  }\n}\n\nfunction fetchWithTimeout(\n  url: string,\n  options: RequestInit & FetchWithTimeoutOptions = {}\n): Promise<Response> {\n  const { timeout = 10000, ...fetchOptions } = options;\n  const controller = new AbortController();\n  const timeoutId = setTimeout(() => controller.abort(), timeout);\n\n  return fetch(url, {\n    ...fetchOptions,\n    signal: controller.signal,\n  })\n    .then(response => {\n      clearTimeout(timeoutId);\n      return response;\n    })\n    .catch(error => {\n      clearTimeout(timeoutId);\n      if (error.name === 'AbortError') {\n        throw new TimeoutError(`Request to ${url} timeout after ${timeout}ms`);\n      }\n      throw error;\n    });\n}\n\n// useAsync内での使用例\nfunction useAsyncWithTimeout<T>(\n  asyncFunction: () => Promise<T>,\n  timeout: number = 10000\n) {\n  const [data, setData] = useState<T | null>(null);\n  const [loading, setLoading] = useState(false);\n  const [error, setError] = useState<Error | null>(null);\n  const abortControllerRef = useRef<AbortController | null>(null);\n\n  const execute = useCallback(async () => {\n    setLoading(true);\n    setError(null);\n    abortControllerRef.current = new AbortController();\n\n    const timeoutId = setTimeout(() => {\n      abortControllerRef.current?.abort();\n    }, timeout);\n\n    try {\n      const result = await asyncFunction();\n      setData(result);\n    } catch (err) {\n      if (err instanceof TimeoutError) {\n        setError(new Error('リクエストがタイムアウトしました'));\n      } else {\n        setError(err instanceof Error ? err : new Error('Unknown error'));\n      }\n    } finally {\n      clearTimeout(timeoutId);\n      setLoading(false);\n    }\n  }, [asyncFunction, timeout]);\n\n  const cancel = useCallback(() => {\n    abortControllerRef.current?.abort();\n    setLoading(false);\n  }, []);\n\n  return { data, loading, error, execute, cancel };\n}\n\nexport { fetchWithTimeout, TimeoutError, useAsyncWithTimeout };

実装パターン6:グローバルエラーハンドラー

複数のエラーハンドリング方法を統合するには、グローバルなエラーコンテキストが有効です。

import React, { createContext, useContext, useState, useCallback } from 'react';\n\nexport interface AppError {\n  id: string;\n  message: string;\n  type: 'error' | 'warning' | 'info';\n  duration?: number;\n  timestamp: number;\n}\n\ninterface ErrorContextType {\n  errors: AppError[];\n  addError: (message: string, type?: 'error' | 'warning' | 'info', duration?: number) => string;\n  removeError: (id: string) => void;\n  clearErrors: () => void;\n}\n\nconst ErrorContext = createContext<ErrorContextType | undefined>(undefined);\n\nexport function ErrorProvider({ children }: { children: React.ReactNode }) {\n  const [errors, setErrors] = useState<AppError[]>([]);\n\n  const addError = useCallback(\n    (message: string, type: 'error' | 'warning' | 'info' = 'error', duration = 5000) => {\n      const id = `${Date.now()}-${Math.random()}`;\n      const newError: AppError = {\n        id,\n        message,\n        type,\n        duration,\n        timestamp: Date.now(),\n      };\n\n      setErrors(prev => [...prev, newError]);\n\n      // 自動削除\n      if (duration) {\n        setTimeout(() => removeError(id), duration);\n      }\n\n      return id;\n    },\n    []\n  );\n\n  const removeError = useCallback((id: string) => {\n    setErrors(prev => prev.filter(error => error.id !== id));\n  }, []);\n\n  const clearErrors = useCallback(() => {\n    setErrors([]);\n  }, []);\n\n  const value = { errors, addError, removeError, clearErrors };\n\n  return (\n    <ErrorContext.Provider value={value}>\n      {children}\n    </ErrorContext.Provider>\n  );\n}\n\nexport function useGlobalError() {\n  const context = useContext(ErrorContext);\n  if (!context) {\n    throw new Error('useGlobalError must be used within ErrorProvider');\n  }\n  return context;\n}\n\n// グローバルエラー表示コンポーネント\nexport function ErrorNotificationContainer() {\n  const { errors, removeError } = useGlobalError();\n\n  return (\n    <div style={{\n      position: 'fixed',\n      top: '20px',\n      right: '20px',\n      zIndex: 9999,\n      maxWidth: '400px',\n    }}>\n      {errors.map(error => (\n        <div\n          key={error.id}\n          style={{\n            padding: '12px 16px',\n            marginBottom: '8px',\n            borderRadius: '4px',\n            backgroundColor: {\n              error: '#fff2f0',\n              warning: '#fffbe6',\n              info: '#e6f7ff',\n            }[error.type],\n            color: {\n              error: '#f5222d',\n              warning: '#faad14',\n              info: '#1890ff',\n            }[error.type],\n            border: `1px solid ${{ error: '#ffccc7', warning: '#ffe58f', info: '#91d5ff' }[error.type]}`,\n          }}\n        >\n          
\n {error.message}\n \n ×\n \n
\n </div>\n ))}\n </div>\n );\n}

使用例:


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