JavaScript fetch APIでHTTPリクエストを確実に送信する実装パターン

fetch APIはモダンなブラウザで標準装備されたHTTP通信の手段です。この記事では、APIからのデータ取得、フォーム送信、エラーハンドリングまで、実務で必要な実装パターンをすぐに活用できる形で解説します。

fetch APIとは:XMLHttpRequestからの進化

fetch APIは、従来のXMLHttpRequestに代わる、より簡潔で扱いやすいHTTP通信の仕組みです。Promise ベースの設計により、async/awaitを使った読みやすい非同期処理が実現できます。

使うべき場面:REST APIの呼び出し、データの動的読み込み、フォーム送信

使うべきでない場面:IE 11 のサポートが必須の場合(ポリフィルが必要)、アップロード進捗の監視が必要な場合(XMLHttpRequestが適切)

基本的なGETリクエストの実装

最もシンプルな使用例として、公開APIからデータを取得する方法を見てみましょう。

// 基本的なGETリクエスト
fetch('https://api.example.com/users')
  .then(response => response.json())
  .then(data => console.log(data))
  .catch(error => console.error('エラー:', error));

async/awaitを使った書き方がより現代的です:

// async/awaitを使った実装(推奨)
async function getUsers() {
  try {
    const response = await fetch('https://api.example.com/users');
    
    // ステータスコードの確認
    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }
    
    const data = await response.json();
    console.log(data);
    return data;
  } catch (error) {
    console.error('データ取得に失敗しました:', error);
  }
}

getUsers();

重要なハマりポイント:response.okチェックを忘れずに

多くの初心者がやりがちなミスは、fetchが「ネットワークエラーのみ」で失敗するという特性です。404や500などのHTTPエラーでは例外が発生しません。必ずresponse.okまたはresponse.statusを確認してください。

POSTリクエストでデータを送信する

フォーム送信やデータ作成時は、リクエストボディにデータを含める必要があります。

// JSONデータを送信するPOSTリクエスト
async function createUser(userData) {
  try {
    const response = await fetch('https://api.example.com/users', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': 'Bearer YOUR_TOKEN' // 認証が必要な場合
      },
      body: JSON.stringify(userData)
    });
    
    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }
    
    const result = await response.json();
    console.log('作成成功:', result);
    return result;
  } catch (error) {
    console.error('送信に失敗しました:', error);
  }
}

// 使用例
createUser({
  name: '田中太郎',
  email: 'tanaka@example.com',
  age: 30
});

実務的なエラーハンドリングと再試行ロジック

本番環境ではネットワーク不具合の可能性があるため、再試行ロジックが重要です。

// 再試行機能付きのfetch ラッパー関数
async function fetchWithRetry(url, options = {}, retries = 3) {
  const maxRetries = retries;
  
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      const response = await fetch(url, options);
      
      if (!response.ok) {
        // ステータスコードが400番台の場合は再試行しない
        if (response.status >= 400 && response.status < 500) {
          throw new Error(`クライアントエラー: ${response.status}`);
        }
        throw new Error(`サーバーエラー: ${response.status}`);
      }
      
      return await response.json();
    } catch (error) {
      console.warn(`試行 ${attempt}/${maxRetries} 失敗:`, error.message);
      
      if (attempt === maxRetries) {
        throw new Error(`${maxRetries}回の試行後も失敗しました: ${error.message}`);
      }
      
      // 指数バックオフ: 1秒、2秒、4秒...の間隔で再試行
      const delay = Math.pow(2, attempt - 1) * 1000;
      await new Promise(resolve => setTimeout(resolve, delay));
    }
  }
}

// 使用例
try {
  const data = await fetchWithRetry('https://api.example.com/data');
  console.log('取得成功:', data);
} catch (error) {
  console.error('最終的に失敗:', error.message);
}

タイムアウト設定とAbortControllerの活用

デフォルトではfetch APIにタイムアウト機能がないため、AbortControllerを使って手動で実装する必要があります。

// タイムアウト機能付きfetch
async function fetchWithTimeout(url, options = {}, timeoutMs = 5000) {
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
  
  try {
    const response = await fetch(url, {
      ...options,
      signal: controller.signal
    });
    
    clearTimeout(timeoutId);
    
    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }
    
    return await response.json();
  } catch (error) {
    clearTimeout(timeoutId);
    
    if (error.name === 'AbortError') {
      throw new Error(`リクエストがタイムアウトしました(${timeoutMs}ms)`);
    }
    throw error;
  }
}

// 使用例:5秒でタイムアウト
try {
  const data = await fetchWithTimeout('https://api.example.com/slow', {}, 5000);
  console.log(data);
} catch (error) {
  console.error(error.message);
}

フォームデータの送信(multipart/form-data)

ファイルアップロードを含む場合は、FormDataオブジェクトを使用します。

// ファイル付きフォーム送信
async function submitFormWithFile(fileInput, formData) {
  try {
    const form = new FormData();
    
    // テキストデータを追加
    form.append('name', formData.name);
    form.append('description', formData.description);
    
    // ファイルを追加
    if (fileInput.files.length > 0) {
      form.append('file', fileInput.files[0]);
    }
    
    const response = await fetch('https://api.example.com/upload', {
      method: 'POST',
      body: form
      // Content-Typeヘッダーを明示的に設定してはいけない
      // ブラウザが自動的に設定する
    });
    
    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }
    
    const result = await response.json();
    console.log('アップロード成功:', result);
    return result;
  } catch (error) {
    console.error('アップロード失敗:', error);
  }
}

// HTML側
const fileInput = document.querySelector('input[type="file"]');
const submitBtn = document.querySelector('button[type="submit"]');

submitBtn.addEventListener('click', () => {
  submitFormWithFile(fileInput, {
    name: '新しいファイル',
    description: 'テスト用のアップロード'
  });
});

リクエストのキャンセルと複数リクエストの管理

ユーザーがページを離れたり、検索をキャンセルしたときは、実行中のリクエストを中止することが重要です。

// リクエストキャンセル機能を持つクラス
class APIClient {
  constructor() {
    this.controllers = new Map();
  }
  
  // 名前付きリクエスト送信
  async fetch(requestName, url, options = {}) {
    // 同じ名前の前のリクエストがあればキャンセル
    if (this.controllers.has(requestName)) {
      this.controllers.get(requestName).abort();
    }
    
    const controller = new AbortController();
    this.controllers.set(requestName, controller);
    
    try {
      const response = await fetch(url, {
        ...options,
        signal: controller.signal
      });
      
      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`);
      }
      
      return await response.json();
    } finally {
      this.controllers.delete(requestName);
    }
  }
  
  // 指定名のリクエストをキャンセル
  cancel(requestName) {
    if (this.controllers.has(requestName)) {
      this.controllers.get(requestName).abort();
      console.log(`リクエスト「${requestName}」をキャンセルしました`);
    }
  }
}

// 使用例
const client = new APIClient();

// 検索ボタン
const searchBtn = document.querySelector('#search-btn');
const cancelBtn = document.querySelector('#cancel-btn');

searchBtn.addEventListener('click', async () => {
  try {
    const results = await client.fetch(
      'search',
      'https://api.example.com/search?q=JavaScript'
    );
    console.log('検索結果:', results);
  } catch (error) {
    if (error.name === 'AbortError') {
      console.log('検索がキャンセルされました');
    } else {
      console.error('検索エラー:', error);
    }
  }
});

cancelBtn.addEventListener('click', () => {
  client.cancel('search');
});

よくある質問

fetch はブラウザの標準 API で追加ライブラリが不要、axios はリクエスト/レスポンスインターセプター機能が充実しています。小規模プロジェクトなら fetch、複雑な前処理後処理が必要なら axios を選ぶと良いでしょう。

クライアント側では解決できません。サーバー側で CORS ヘッダー(Access-Control-Allow-Originなど)を設定する必要があります。開発環境では CORS プロキシを使用するか、サーバーチームに依頼してください。

最新の全ブラウザで対応しています。IE 11 が必須環境の場合はポリフィルの使用が必要です。whatwg-fetch ライブラリが定番です。

まとめ

  • fetch は Promise ベースで async/await を組み合わせると読みやすいコードになる
  • response.ok の確認を忘れずに。HTTPエラーは例外として発生しない
  • 本番環境では再試行ロジックとタイムアウト設定が必須
  • AbortController を使うことでリクエストのキャンセルが可能
  • ファイルアップロードは FormData を使用し、ヘッダーはブラウザに任せる
  • 複雑なリクエスト管理が必要な場合はクラスでラップして再利用性を高める

fetch API の使い方を習得することで、モダンな非同期処理が書けるようになります。公式ドキュメント(MDN - Fetch API)も参考にしながら、自分のプロジェクトに適した実装パターンを選んでください。

動作確認環境:macOS 14 / Chrome 121 / Node.js 20.10で検証済み

K
AWS・Python・生成AIを専門とするソフトウェアエンジニア。AI・クラウド・開発ワークフローの実践ガイドを執筆しています。詳しく見る →