🐕

React+TypeScript fetchでの非同期処理とエラーハンドリング

に公開

はじめに
React + TypeScriptでAPIを呼び出す際、エラーハンドリングは避けて通れません。しかし、「とりあえずtry-catchで囲む」だけでは不十分です。この記事では、実践的なエラーハンドリングパターンを解説します。
基本的なtry-catch-finallyパターン

typescriptconst handleNameSearch = async (e: React.FormEvent) => {
  e.preventDefault();
  
  onLoadingChange(true);  // ローディング開始
  
  try {
    const response = await fetch('http://localhost:3000/api/v1/wines/search_by_name', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ query: searchQuery }),
    });

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

    const data: WineResponse = await response.json();
    onResult(data);
    
  } catch (error) {
    onError(error instanceof Error ? error.message : '検索エラーが発生しました');
    
  } finally {
    onLoadingChange(false);  // 必ずローディング終了
  }
};

このパターンの重要なポイントは3つです。

1. finallyでローディング状態を確実に解除

finallyブロックは、成功・失敗に関わらず必ず実行されます。

typescripttry {
  onLoadingChange(true);
  // API呼び出し
} catch (error) {
  // エラー処理
} finally {
  onLoadingChange(false);  // 成功でも失敗でも必ず実行
}

もしfinallyがないと:

onLoadingChange(true);
try {
  const response = await fetch(...);
  onLoadingChange(false);  // 成功時のみ実行
} catch (error) {
  onLoadingChange(false);  // エラー時にも書く必要がある
}

同じコードを2箇所に書くことになり、保守性が下がります。

2. response.okのチェック

fetchはネットワークエラー以外ではエラーをthrowしません。HTTPステータスコード400や500でも、catchブロックには入りません。

typescriptconst response = await fetch(...);

// fetchは404や500でもエラーをthrowしない!
// 明示的にチェックが必要
if (!response.ok) {
  throw new Error('検索に失敗しました');
}
レスポンスステータスに応じたエラー処理:
typescriptif (!response.ok) {
  switch (response.status) {
    case 400:
      throw new Error('リクエストが不正です');
    case 404:
      throw new Error('ワインが見つかりませんでした');
    case 500:
      throw new Error('サーバーエラーが発生しました');
    default:
      throw new Error(`エラーが発生しました (${response.status})`);
  }
}

3. 型ガードでエラーの型を安全に扱う

JavaScriptでは、catchでキャッチされるエラーはunknown型です。TypeScriptでは型安全に扱う必要があります。

typescriptcatch (error) {
  // errorはunknown型
  onError(error instanceof Error ? error.message : '検索エラーが発生しました');
}

instanceof Errorで型ガードを行い、Errorオブジェクトならmessageプロパティにアクセスできます。

より実践的なパターン

  • パターン1: カスタムエラークラス
typescriptclass ApiError extends Error {
  constructor(
    message: string,
    public statusCode: number,
    public details?: unknown
  ) {
    super(message);
    this.name = 'ApiError';
  }
}

const handleApiRequest = async () => {
  try {
    const response = await fetch(...);
    
    if (!response.ok) {
      const errorData = await response.json();
      throw new ApiError(
        errorData.message || 'リクエストに失敗しました',
        response.status,
        errorData
      );
    }
    
    return await response.json();
    
  } catch (error) {
    if (error instanceof ApiError) {
      console.error(`API Error ${error.statusCode}:`, error.message);
      // ステータスコードに応じた処理
    } else if (error instanceof Error) {
      console.error('Network Error:', error.message);
    }
    throw error;
  }
};
  • パターン2: エラーメッセージの一元管理
typescriptconst ERROR_MESSAGES = {
  NETWORK: 'ネットワークエラーが発生しました',
  TIMEOUT: 'リクエストがタイムアウトしました',
  SERVER: 'サーバーエラーが発生しました',
  NOT_FOUND: 'データが見つかりませんでした',
  VALIDATION: '入力内容に誤りがあります',
} as const;

const handleError = (error: unknown): string => {
  if (error instanceof TypeError) {
    return ERROR_MESSAGES.NETWORK;
  }
  
  if (error instanceof Error) {
    if (error.message.includes('timeout')) {
      return ERROR_MESSAGES.TIMEOUT;
    }
    return error.message;
  }
  
  return '予期しないエラーが発生しました';
};

// 使用例
catch (error) {
  const message = handleError(error);
  onError(message);
}
  • パターン3: リトライ機能付きfetch
typescriptconst fetchWithRetry = async (
  url: string,
  options: RequestInit,
  retries = 3
): Promise<Response> => {
  try {
    const response = await fetch(url, options);
    
    if (!response.ok && response.status >= 500 && retries > 0) {
      // サーバーエラーの場合はリトライ
      await new Promise(resolve => setTimeout(resolve, 1000));
      return fetchWithRetry(url, options, retries - 1);
    }
    
    return response;
    
  } catch (error) {
    if (retries > 0) {
      await new Promise(resolve => setTimeout(resolve, 1000));
      return fetchWithRetry(url, options, retries - 1);
    }
    throw error;
  }
};

// 使用例
try {
  const response = await fetchWithRetry('http://localhost:3000/api/v1/wines', {
    method: 'POST',
    body: JSON.stringify(data),
  });
  
  const result = await response.json();
} catch (error) {
  onError('リトライ後もエラーが発生しました');
}
  • パターン4: タイムアウト処理
typescriptconst fetchWithTimeout = async (
  url: string,
  options: RequestInit,
  timeout = 5000
): Promise<Response> => {
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), timeout);
  
  try {
    const response = await fetch(url, {
      ...options,
      signal: controller.signal,
    });
    return response;
    
  } catch (error) {
    if (error instanceof Error && error.name === 'AbortError') {
      throw new Error('リクエストがタイムアウトしました');
    }
    throw error;
    
  } finally {
    clearTimeout(timeoutId);
  }
};

まとめ

TypeScriptでの非同期エラーハンドリングのポイント:

-finallyでクリーンアップ処理を確実に実行
-response.okで明示的にHTTPエラーをチェック
-型ガード(instanceof)で型安全にエラーを扱う
-カスタムエラークラスで詳細な情報を保持
-エラーメッセージは一元管理して保守性を高める

Discussion