JavaScript中級 #6 fetch API とエラー処理

読了 6分

#5 オプショナルチェーンと nullish 合体に続いて、今回はJavaScriptで外部の世界と通信するツール。ブラウザと Node の両方で動作する標準 fetch API。

fetch 基礎 #

最も単純な fetch
const response = await fetch('https://api.example.com/users');
const users = await response.json();
console.log(users);

三行で終わります。fetch は Promise を返すので — await で解きます(#2 非同期入門で見たパターン)。

fetch の結果は Response オブジェクトです。レスポンスボディをどう解釈するかによってメソッドが異なります。

レスポンス解釈メソッド
await response.json();        // JSON パース
await response.text();        // テキストそのまま
await response.blob();        // バイナリ (画像/ファイルなど)
await response.arrayBuffer(); // ArrayBuffer (低レベル)
await response.formData();    // FormData

落とし穴 1 — 4xx/5xx は catch では捕まらない #

JavaScript の fetch は HTTP ステータスコードが 4xx/5xx でも reject しません。ネットワーク自体が失敗したときだけ reject します。

これは throw が起こらない
try {
  const res = await fetch('/api/not-found');
  // ステータスが 404 でもここまで来る
  console.log(res.status);   // 404
} catch (err) {
  // ネットワーク失敗でなければ入ってこない
}

この動作はほかの言語/ライブラリ(axios など)と異なり、混乱をよく引き起こします。ステータスチェックを自分でしなければなりません。

推奨パターン — ステータスを自分でチェック
const res = await fetch('/api/users');
if (!res.ok) {
  throw new Error(`HTTP ${res.status}`);
}
const data = await res.json();

res.ok はステータスが 2xx の範囲なら true。このチェックを抜かすと 4xx レスポンスをそのまま JSON パースしようとして別のエラーにつながります。

メソッドとボディ — POST/PUT/DELETE #

基本は GET。ほかのメソッドを使うにはオプションオブジェクトを二番目の引数として渡します。

POST リクエスト
const res = await fetch('/api/users', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ name: 'カーティス', age: 30 }),
});

if (!res.ok) throw new Error(`HTTP ${res.status}`);
const created = await res.json();

三つの核心:

  1. method — 既定 ‘GET’、POST/PUT/PATCH/DELETE など
  2. headersContent-Type でボディ形式を明示
  3. body — 送る本文。オブジェクトは直接送れず JSON.stringify で文字列化

FormData を送る #

ファイルアップロードのように multipart ボディが必要なら:

FormData
const formData = new FormData();
formData.append('name', 'カーティス');
formData.append('avatar', fileInput.files[0]);

const res = await fetch('/api/upload', {
  method: 'POST',
  body: formData,
  // Content-Type はあえて書かない — ブラウザが自動設定
});

FormData を送るときは Content-Type を直接書きません。ブラウザが boundary を含んだ正確なヘッダーを自動で作ってくれます。

ヘッダーと認証 #

認証トークン
const token = localStorage.getItem('token');

const res = await fetch('/api/me', {
  headers: {
    'Authorization': `Bearer ${token}`,
  },
});

クッキーを一緒に送るには credentials オプション:

クッキー含む
const res = await fetch('/api/me', {
  credentials: 'include',  // 別の origin にもクッキーを送る
});

既定値は 'same-origin' (同じドメインのときだけクッキー)。外部 API がクッキー認証を使うなら 'include' が必要です。

クエリパラメータ — URLSearchParams #

URL にクエリ文字列を付けるときに直接文字列を作るのは危険です。URLSearchParams がエンコーディングまで処理してくれます。

安全なクエリ
const params = new URLSearchParams({
  q: '検索語 with spaces',
  page: '1',
  sort: 'date',
});

const res = await fetch(`/api/posts?${params}`);
// /api/posts?q=%E6%A4%9C%E7%B4%A2%E8%AA%9E+with+spaces&page=1&sort=date

+&、日本語、空白すべて自動的に URL エンコードされます。

AbortController — リクエスト取り消し #

長くかかるリクエストを途中で取り消したいとき。

AbortController
const controller = new AbortController();

setTimeout(() => controller.abort(), 5000);   // 5秒後にキャンセル

try {
  const res = await fetch('/api/slow', {
    signal: controller.signal,
  });
  console.log(await res.json());
} catch (err) {
  if (err.name === 'AbortError') {
    console.log('キャンセル済み');
  } else {
    throw err;
  }
}

これが#2 非同期入門で少し言及した timeout のモダンな答えです。Promise.race + タイマーよりすっきりしています。

AbortSignal.timeout() — ES2022 #

よく使うパターンなので短い形も追加されました。

タイムアウトを一行で
const res = await fetch('/api/slow', {
  signal: AbortSignal.timeout(5000),
});

5秒超過時に自動で abort。AbortController を直接作る必要がありません。

同時リクエスト取り消し — コンポーネントアンマウントパターン #

React コンポーネントがアンマウントされたのに、その間進行中だった fetch の結果が遅れて到着すると、なくなったコンポーネントに setState を呼んで警告が出ます。AbortController が標準的な答えです。

コンポーネントで
function UserPage({ id }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    const controller = new AbortController();

    fetch(`/api/users/${id}`, { signal: controller.signal })
      .then((r) => r.json())
      .then(setUser)
      .catch((err) => {
        if (err.name !== 'AbortError') console.error(err);
      });

    return () => controller.abort();   // cleanup
  }, [id]);

  // ...
}

return () => controller.abort() が核心。アンマウントや依存変更時に進行中だった fetch が取り消されます。

ストリーミングレスポンス — for await #

大きなレスポンスを chunk 単位で受け取りたいとき。

ストリームを読む
const res = await fetch('/api/large');

for await (const chunk of res.body) {
  console.log(chunk);   // Uint8Array の断片
}

res.body は ReadableStream。for await (#3 イテレータとジェネレータ)で chunk を順番に受け取ります。AI streaming レスポンス、大きなファイル、Server-Sent Events のような場面で出会います。

Node での fetch #

昔の Node では fetch がなくて node-fetch パッケージをインストールする必要がありました。Node 18 から fetch がビルトインです。追加インストールなしですぐ使えます。

Node でも同じ
const res = await fetch('https://api.example.com/users');
const users = await res.json();

スクリプト、CLI ツール、サーバーサイドすべて同じ API で通信できます。

ロギングとリトライ — 小さな wrapper #

複数の箇所で同じパターン(ステータスチェック、エラーロギング)が繰り返されるなら wrapper 関数を作るのがすっきりします。

シンプルな wrapper
async function api(url, options = {}) {
  const res = await fetch(url, {
    ...options,
    headers: {
      'Content-Type': 'application/json',
      ...options.headers,
    },
  });

  if (!res.ok) {
    throw new Error(`HTTP ${res.status}: ${url}`);
  }

  return res.json();
}

// 使用例
const users = await api('/api/users');
const created = await api('/api/users', {
  method: 'POST',
  body: JSON.stringify({ name: 'カーティス' }),
});

これをさらに発展させると — リトライ、タイムアウト、認証トークン自動追加などを一箇所で管理できます。大きなアプリならば結局似た wrapper が自分なりの形で作られます。

ライブラリを使うか、fetch だけで行くか #

axios、ky、ofetch のような fetch の上に載せるライブラリも多いです。それぞれのトレードオフ:

選択肢利点欠点
ビルトイン fetch依存関係0、標準4xx チェック直接、ボイラープレート
ky小さくモダン、fetch ベース依存関係追加
axios豊富な機能、昔から標準重く、カスタム動作が多い
TanStack Queryキャッシング/同期まで学習コスト

小さなプロジェクト、ライブラリ、学習コードは fetch だけで十分です。大きなアプリなら通常 wrapper またはデータフェッチライブラリ(TanStack Query)と組み合わさる流れです。

まとめ #

この記事で整理した内容:

  • fetch は Promise を返し、レスポンスは Response オブジェクト
  • .json().text().blob() でボディを解釈
  • 4xx/5xx は自動で throw されない — res.ok を自分でチェック
  • POST は method/headers/body オプション + JSON.stringify
  • FormData は Content-Type 自動
  • URLSearchParams で安全なクエリエンコーディング
  • AbortController / AbortSignal.timeout() で取り消し
  • for await (chunk of res.body) でストリーミング
  • Node 18+ もビルトイン fetch
  • 小さな wrapper / TanStack Query のようなライブラリは大きなアプリで価値あり

次の記事(#7 JSON の扱いとシリアライズ)では、JavaScript データを JSON でやりとりするときによく出会う場面 — parse/stringify のオプション、Date/特殊値処理、深いコピー トリックの落とし穴まで整理します。

X