12k
All articles

ReactアプリをAxiosからFetchへ移行する

ReactアプリをAxiosからfetchへ、安全なラッパー、エラー処理、タイムアウト、コピペ可能なクライアントで回帰なく移行します。

OpenReplay Team
OpenReplay Team
ReactアプリをAxiosからFetchへ移行する

ReactアプリをAxiosからfetchへ移行することは、呼び出し箇所を書き直すのではなく、ラッパーの背後にあるライブラリを置き換えるのであれば、リスクの低い変更です。そうしない場合は、デグレードの地雷原になりかねません。この2つのクライアントは同じ問題を異なる方法で解決しています。fetchはネイティブで依存関係のないブラウザ標準であり、生の素材を提供します。一方Axiosは、ベースURL、自動JSON変換、エラーレスポンス時の自動リジェクト、response.data、インターセプター、タイムアウトといったエルゴノミクスをバンドルしており、これらを自分で再実装する必要があります。本ガイドでは、まず「移行すべきか」という問いに答え、実際のReactアプリに対して段階的かつデグレードを防ぎながら移行する手順を示します。コピー&ペーストで使える完全なクライアント実装も含まれています。

主なポイント

  • 最も危険な違いは1つ:fetch()はHTTP 4xxや5xxでPromiseをリジェクトしません。サーバーがレスポンスを返すと成功として解決され、ok: falseResponseオブジェクトが渡されます。つまり、response.okを自分でチェックしない限り、500番台のエラーが200番台と同様にtryブロックを通過してしまいます。
  • 呼び出し箇所ではなくラッパーを移行する:新しいfetchクライアントが同じget/post/patch/deleteシグネチャを公開し、同じPromiseを返すのであれば、React Query、ミューテーション、エラーハンドリングはAxiosがなくなったことに気づきません。
  • fetchにAxiosが持っていたリクエストタイムアウトを実装するには、AbortSignal.timeout(8000)を使用してください。これは2024年4月以降、主要ブラウザでBaselineとして利用可能になっており、タイムアウト時にTimeoutErrorでアボートします。
  • fetchはもはやブラウザ専用ではありません。Node.js v21以降では安定したグローバル関数として利用可能であり、「Node.jsにはAxiosを使い続けるべき」という古い通説はもはや当てはまりません。
  • 順序が重要です:まずすべてのインポートを新しいクライアントに切り替えてから、axiosを削除してロックファイルを更新してください。逆の順序で行うと、移行の途中でビルドが壊れます。

AxiosとFetch:結論

fetchを選ぶべき場合は、Axiosの機能のごく一部しか使っておらず、ランタイム依存関係を削除したい場合です。Axiosの高度な機能に依存している場合は、Axiosを維持してください。 Axiosはベースurl、自動JSON変換、失敗したレスポンスの自動リジェクト、response.data、インターセプターを標準で提供します。fetchでは、アプリが実際に使用している機能のサブセットのみを再実装します。それ以上でも以下でもありません。リクエストを1つのAPIモジュールに集約している多くのReact SPAでは、そのサブセットは小さいものです。

移行する動機は具体的です:

  • バンドルサイズ。 Axiosは追加のランタイム依存関係であり、バージョンによって異なりますが、minify・gzip後で約13〜14 KBあります。一方、fetchラッパーは自分で管理できる数百行のコードであり、ツリーシェイキングも可能です。正確な数値はリリースごとに変わるため、ハードコードされた数値を信頼するのではなく、Bundlephobiaなどのツールでインストール済みのバージョンに対して計測してください。
  • 依存関係の削減とサプライチェーンリスクの低減。 依存関係を減らすことはセキュリティ上の姿勢でもあります。2026年3月、攻撃者がnpmに2つのバックドア入りAxiosバージョンを公開しました。侵害されたメンテナーアカウントを通じて行われたもので、フローティングバージョン範囲を使用しているすべてのプロジェクトが次回のインストール時にそれらを取得してしまいました。メンテナーによるポストモーテム(GitHub issue #10636)Microsoftのセキュリティアドバイザリはいずれも、これがAxiosのソースコードの欠陥ではなく、配布・アカウントの侵害であることを確認しています。悪意のあるバージョンは数時間以内に削除されましたが、CISAもアラートを発行しました。教訓は「Axiosが安全でない」ということではなく、依存関係が1つ減れば、代わりに侵害される可能性のあるアカウントも1つ減るということです。
  • ごく一部の機能しか使っていない。 コードがaxios.get(url).then(r => r.data)だけであれば、8行のfetchで実現できることのためにライブラリを使っていることになります。

Axiosを維持すべき場合

以下のいずれかに該当する場合は、Axiosを維持してください。移行する価値はありません。

依存している機能fetchで困難な理由
リクエスト/レスポンスインターセプターの多用fetchにはインターセプターシステムがなく、自分で再実装が必要
アップロード進捗UIfetchのリクエストボディはアップロード進捗イベントを公開しないため、XMLHttpRequestが必要
レガシーブラウザサポート(IE11など)fetchはそれらのブラウザでは利用不可
Axios固有の設定の広範な使用(paramsSerializer、トランスフォーマー、アダプターなど)再実装のコストが依存関係削減のメリットを上回る

このリストにもはや含まれない理由の1つがNode.jsです。fetchはもはやブラウザ専用ではありません。Node.js 18(2022年4月)でデフォルトで有効なグローバルfetchが導入され、Node.js 21(2023年10月)でfetchとWeb Streamsが安定版に昇格しました。機能ごとの詳細な比較については、LogRocketのAxios vs Fetch比較記事でダウンロード進捗、ストリーミング、キャンセルについて詳しく説明されています。

埋めなければならない動作上のギャップ

Axiosが暗黙的に行っていた6つのことをfetchは行いません。これらはいずれも、コンパイルして実行はされるものの動作が異なるという、サイレントな動作変更です。ラッパーを書く前にこれらのギャップを把握しておきましょう。

  1. fetchは4xx/5xxでリジェクトしない。 これが最大の落とし穴です。MDNのFetch APIリファレンスによると、サーバーがヘッダーを含むレスポンスを返した時点でPromiseは解決されます。HTTPエラーステータスであっても同様です。 500番台のレスポンスはresponse.ok === falseとして解決されます。Axiosは2xx以外を自動的に.catch()にリジェクトしますが、fetchはそうしません。response.okを確認して自分でthrowする必要があります。
  2. 自動JSON変換がない。 Axiosはリクエストボディを文字列化し、レスポンスをパースします。fetchでは、送信時にJSON.stringify()を呼び出し、受信時にawait response.json()を呼び出す必要があり、Content-Type: application/jsonも手動で設定する必要があります。
  3. baseURLがない。 Axiosは設定されたベースURLを先頭に付加します。fetchは完全なURLまたはドキュメントからの相対URLを受け取るため、パスの結合は自分で行う必要があります。
  4. response.dataがない。 パースされたボディは.dataプロパティではなく、await response.json()の戻り値として取得します。
  5. インターセプターがない。 認証トークンの付加やグローバルな401ハンドリングのための組み込みフックはありません。
  6. タイムアウトオプションがない。 Axiosにはtimeout設定がありますが、fetchにはありません。AbortSignalを使用する必要があります。

変更前後の比較

同じリクエストをAxiosと、これから構築するクライアントで記述した場合:

// Axios — エラー時の自動リジェクト、response.data、自動JSON変換
const { data } = await axios.get(`/users/${id}`);
return data;

// fetch(生) — okチェックが必要、パースが必要、baseURLなし
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error(res.statusText); // <-- これがないと500が成功として扱われる
return res.json();

戦略:呼び出し箇所ではなくラッパーを移行する

コンポーネント、フック、React Queryの呼び出しには手を触れないでください。既存のAPIサーフェスの背後にあるライブラリを置き換えます。 アプリが既にget/post/patch/deleteを公開する単一モジュールを通じてリクエストをルーティングしているなら、新しい実装を動作互換にするだけでよく、Axios互換にする必要はありません。同一のシグネチャを維持し、同じPromiseを返し、同じエラー型をthrowすれば、下流のコードはすべてそのままで動作します。これが移行をリスクの高いものではなく、退屈なものにする秘訣です。

手順:

  1. 使用状況を調査する。 呼び出しが1つのラッパーを経由していることを確認します。そうでない場合、最初のPRはそれらを1つのラッパーにルーティングすることです。このリファクタリングはfetchとは独立しており、単独で安全にリリースできます。
  2. エラーコントラクトを合わせる。 UIがerror.code === 'EXPIRED'で分岐したりerror.statusを読み取ったりしている場合、新しいクライアントは同じフィールドを持つエラーを生成する必要があります。ここでの不一致はサイレントな破壊につながります。
  3. Promiseの形状を維持する。 React Query、SWR、ミューテーションが期待するのは、データで解決するか、エラーでリジェクトするPromiseです。どのライブラリがそれを生成したかは関係ありません。

コピーして使える完全なfetchクライアント

この完全なapi-client.tsは、比較で挙げたすべてのギャップを解消します。ベースURLの結合、クエリパラメーター、ヘッダーのマージ、JSONボディ、エラーレスポンスをリジェクトするokチェック、カスタムエラークラス、認証トークンの注入、中央集権的な401ハンドリング、AbortSignal.timeout()によるタイムアウトが含まれています。このファイルを配置してインポートを向け直すだけで、呼び出し箇所はそのまま動作します。

// lib/api-client.ts
const BASE_URL = import.meta.env.VITE_API_URL ?? "/api";
const DEFAULT_TIMEOUT = 8000;

export class ApiClientError extends Error {
  status: number;
  code?: string;
  data?: unknown;
  constructor(message: string, status: number, code?: string, data?: unknown) {
    super(message);
    this.name = "ApiClientError";
    this.status = status;
    this.code = code;
    this.data = data;
  }
}

type Query = Record<string, string | number | boolean | undefined>;

interface RequestOptions extends Omit<RequestInit, "body"> {
  body?: unknown;
  params?: Query;
  timeout?: number;
}

function buildUrl(path: string, params?: Query): string {
  const base = BASE_URL.endsWith("/") ? BASE_URL : `${BASE_URL}/`;
  const url = new URL(path.replace(/^\//, ""), new URL(base, window.location.origin));
  if (params) {
    for (const [key, value] of Object.entries(params)) {
      if (value !== undefined) url.searchParams.set(key, String(value));
    }
  }
  return url.toString();
}

async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
  const { body, params, timeout = DEFAULT_TIMEOUT, headers, ...rest } = options;
  const token = localStorage.getItem("token");

  let res: Response;
  try {
    res = await fetch(buildUrl(path, params), {
      ...rest,
      headers: {
        "Content-Type": "application/json",
        ...(token ? { Authorization: `Bearer ${token}` } : {}),
        ...headers,
      },
      body: body !== undefined ? JSON.stringify(body) : undefined,
      signal: AbortSignal.timeout(timeout),
    });
  } catch (err) {
    // Chromiumベースのブラウザでは、fetchのタイムアウトがTimeoutErrorではなくAbortErrorとして
    // 発生する場合があります。
    if (err instanceof DOMException && (err.name === "TimeoutError" || err.name === "AbortError")) {
      throw new ApiClientError("Request timed out", 0, "TIMEOUT");
    }
    throw new ApiClientError("Network error", 0, "NETWORK");
  }

  // 落とし穴:fetchは4xx/5xxでも解決される。明示的にリジェクトする。
  if (!res.ok) {
    let payload: any;
    try { payload = await res.json(); } catch { payload = undefined; }

    if (res.status === 401) {              // インターセプター相当:中央集権的な401ハンドリング
      localStorage.removeItem("token");
      window.location.assign("/login");
    }
    throw new ApiClientError(
      payload?.message ?? res.statusText,
      res.status,
      payload?.code,
      payload,
    );
  }

  if (res.status === 204) return undefined as T;
  return (await res.json()) as T;
}

export const api = {
  get:    <T>(path: string, options?: RequestOptions) => request<T>(path, { ...options, method: "GET" }),
  post:   <T>(path: string, body?: unknown, options?: RequestOptions) => request<T>(path, { ...options, method: "POST", body }),
  patch:  <T>(path: string, body?: unknown, options?: RequestOptions) => request<T>(path, { ...options, method: "PATCH", body }),
  put:    <T>(path: string, body?: unknown, options?: RequestOptions) => request<T>(path, { ...options, method: "PUT", body }),
  delete: <T>(path: string, options?: RequestOptions) => request<T>(path, { ...options, method: "DELETE" }),
};

特筆すべき点が2つあります。タイムアウトにはAbortSignal.timeout()を使用しています。これは指定した時間後に自動的にアボートするAbortSignalを返し、タイムアウト時にTimeoutError DOMExceptionでシグナルをアボートします。2024年4月以降、この機能は主要なデバイスとブラウザバージョンで動作します。TimeoutErrorAbortErrorの両方をキャッチしてください。ChromiumベースのブラウザではfetchのタイムアウトがTimeoutErrorではなくAbortErrorとして発生することがあるため、両方の名前を防御的に処理してください。204の短絡処理は、空のボディに対して.json()を呼び出すことを防ぎます。

インターセプターの再実装と、XHRに残すべきもの

ラッパー自体がインターセプターです。 Axiosのインターセプターが行っていたこと、つまりBearerトークンの付加、エラーの正規化、401時のリダイレクトは、すべて上記のrequest()関数に集約されており、すべての呼び出しに自動的に適用されます。プラグインシステムは不要です。すでに1つのチョークポイントがあります。429時のリトライやリクエストログの追加は、ハンドラーを登録するのではなく、request()に数行追加するだけです。

意図的に移行しないものが1つあります:アップロード進捗です。ブラウザのfetchリクエストボディはXMLHttpRequestのようなアップロード進捗イベントを公開しないため、既存のXHRベースのアップロードフローはそのまま維持してください。ハイブリッドアプローチは恥ずかしいことではありません。JSONリクエストの95%にはfetchを使い、ファイルアップロードの進捗バーにはXHRを使うという方法で問題ありません。

移行の手順

以下の手順を順番に実行してください。順序こそが人々が間違えやすい部分です。

  1. まずインポートを切り替える。 すべての呼び出し箇所を新しいクライアントに向け直します(import { api } from "@/lib/api-client")。シグネチャが一致しているため、これは書き直しではなく検索・置換で対応できます。
  2. Axiosを削除してからロックファイルを更新する。 package.jsonからaxiosを削除し、npm install(または使用しているパッケージマネージャーの同等コマンド)を実行してロックファイルを更新します。これはインポートの切り替えに行ってください。順序を逆にすると、未解決のインポートにより移行の途中でビルドが壊れます。2026年6月時点で、npmレジストリにはAxiosバージョン1.18.1が掲載されています。削除せずに安全なバージョンをピン留めしている場合は、レジストリで最新リリースを確認してください。
  3. コードを読むのではなく、ビルドで検証する。 テストスイートを実行し、TypeScriptの型チェック(tsc --noEmit)を行い、プロダクションビルドを実行してください。移行が完了したのは、アプリがビルドされ、型チェックが通り、正しく動作したときです。差分がきれいに見えたときではありません。

最後の点こそが、サイレントなデグレードが潜む場所です。Axiosからfetchへの最も危険なデグレードは、エラーレスポンスが処理されないケースです。500番台を返したリクエストが、fetchがthrowしないために「成功」として処理され、ユーザーがエラーも表示されないまま空白または壊れたUIを見つめることになります。ユニットテストはハッピーパスをモックして通過します。サーバーログには500が記録されているためバックエンドは正常に見えます。「サーバーがエラーをログに記録した」と「ユーザーが何も見えなかった」の間のギャップは、ネットワークキャプチャ付きのセッションリプレイが明らかにするものです。キャプチャされたエラーレスポンスをユーザーが実際に経験した壊れたUIに結びつけることで、テストとサーバーログがそれぞれ単独では見逃すデグレードを検出できます。上記クライアントの!res.okチェックがこの種のバグを最初から防ぐものであり、それが本番環境で正しく機能していることを確認することが、移行が実際に完了したことを証明します。

fetchベースのクライアントは、APIサーフェスを同一に保つ限り、単一のコンポーネントを書き直すことなく、依存関係ツリーの縮小、エラーと認証動作の完全な制御、サプライチェーンにおけるサードパーティアカウントの削減をもたらします。次の具体的なステップは、アプリのリクエストがまだ単一モジュールを経由していない場合はそのようにルーティングし、上記のクライアントを組み込んで、ビルド、型チェック、テストが完了を告げるのを待つことです。

よくある質問

fetchは404や500のレスポンスでエラーをthrowしますか?

いいえ。fetchはネットワーク障害やリクエストが完了しない場合にのみPromiseをリジェクトします。HTTP 404や500はokプロパティがfalseのResponseオブジェクトとして正常に解決されます。つまり、response.okを明示的にチェックして自分でthrowしない限り、サーバーエラーは成功したかのようにtryブロックを通過します。Axiosは動作が異なり、2xx以外のステータスを自動的にcatchパスにリジェクトします。

Node.jsでリクエストを行うためにまだAxiosが必要ですか?

いいえ。fetchは2023年10月にリリースされたNode.js v21以降、安定したグローバル関数として利用可能です。また、2022年4月のNode.js 18以降、デフォルトで有効な実験的グローバルとして利用可能でした。サーバーサイドのリクエストのためにAxiosを維持するという古いアドバイスはもはや当てはまりません。ブラウザとNode.jsのコードで同じfetchベースのクライアントを使用できますが、18より古いNode.jsバージョンではポリフィルまたはライブラリが必要です。

fetchでリクエストタイムアウトを設定するにはどうすればよいですか?

signalオプションとしてAbortSignal.timeout(ミリ秒)を渡します(例:signal: AbortSignal.timeout(8000))。これにより、指定した時間後に自動的にアボートするシグナルが返され、fetchはTimeoutError DOMExceptionでリジェクトされます。2024年4月以降、主要ブラウザでBaselineとして利用可能です。Chromiumベースのブラウザでは、リジェクションがTimeoutErrorではなくAbortErrorとして発生することがあるため、タイムアウトと手動キャンセルを区別する際は両方の名前を防御的にキャッチしてください。

AxiosからfetchへのスイッチはReact QueryやSWRのセットアップを壊しますか?

いいえ。新しいfetchクライアントが同じPromiseを返し、同じメソッドシグネチャを公開している限り問題ありません。React Query、SWR、ミューテーションが必要とするのは、呼び出しがデータで解決するか、エラーでリジェクトするということだけです。どのライブラリがそれを生成したかは関係ありません。get、post、patch、deleteのシグネチャを同一に保ち、UIが分岐する同じフィールドを持つ同じエラー型をthrowすれば、データフェッチ層はAxiosがなくなったことに気づきません。

DevTools for the frontend

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.