fetchと非同期通信
fetchと非同期通信とは
fetch は、ページを再読み込みせずにサーバーとデータをやり取りするためのブラウザ標準APIです。[[SPAとMPA]] でいう SPA が「サーバーとはデータだけをやり取りする」と言うとき、その取りに行く部分を実際に担っているのが fetch です。
const res = await fetch('/api/users/42');
const user = await res.json();
console.log(user.name);
古くは XMLHttpRequest による通信を Ajax と呼びました。fetch はその後継の標準APIで、コールバックではなく Promise を返します。
なぜ「待つ」書き方になるのか
サーバーへの往復には数十〜数百ミリ秒かかります。[[JavaScript]] はシングルスレッドなので、その間ブラウザを止めるわけにはいきません。そこで fetch は結果そのものではなく「あとで結果が入る箱」(Promise)を即座に返し、中身が届くまで制御を他へ譲ります。
初学者が戸惑うのが await が2回出てくる点ですが、これには意味があります。
| 行 | いつ完了するか |
|---|---|
await fetch(...) | ステータスとヘッダーが返ってきた時点 |
await res.json() | 本文を最後まで受信し、[[JSON]] として解釈し終えた時点 |
ヘッダーだけ先に読めるので、本文をダウンロードする前に「これは404だ」と判断して打ち切れます。
最大の落とし穴: 404や500では失敗しない
fetch の Promise は、サーバーがエラーを返しても reject しません。404 も 500 も「通信は成功した」と扱われ、try/catch の catch に入ってきません。
| 状況 | res.ok | Promiseは? |
|---|---|---|
| 200 OK | true | 成功 |
| 404 / 500 | false | 成功扱い(catchに入らない) |
| 通信断・DNS失敗・CORS拒否 | 参照できない | reject(TypeError) |
そのため、毎回 res.ok を確認する必要があります。
const res = await fetch('/api/users/42');
if (!res.ok) {
throw new Error(`API failed: ${res.status}`);
}
return await res.json();
このチェックを忘れると、404なのに res.json() が Unexpected token '<' で落ちるという無関係に見えるエラーだけが出ます。サーバーがJSONではなくHTMLのエラーページを返しているためです。各コードの意味は [[http-status-code]] を参照してください。
ローディング・エラー・成功の3状態
通信を扱う画面では、保持すべき状態は「データ」だけではありません。まだ来ていない・失敗した・届いたの3つを区別して初めて画面が作れます。
let data = null; // 届いたデータ
let error = null; // 失敗の内容
let loading = false; // 通信中か
- 未取得(
null)と 0件([])は別物です。混同すると、読込中にいきなり「データがありません」と表示されます - 検索ボックスのように入力ごとに叩く場合、古いリクエストの応答が後から届いて新しい結果を上書きする競合が起きます
この3状態とキャッシュ・再取得の管理はどの画面でも同じ形になるため、[[状態管理]] で挙げた TanStack Query / SWR のようなサーバー状態専用ライブラリが定番です。再試行の方針は [[エラーハンドリングとリトライ設計]] をそのまま持ち込めます。
途中でやめる(AbortController)
不要になった通信は中断できます。AbortController の signal を fetch に渡し、abort() を呼ぶだけです。
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5000); // 5秒で打ち切り
try {
const res = await fetch(url, { signal: controller.signal });
} catch (err) {
if (err.name === 'AbortError') return; // 中断は「失敗」ではない
throw err;
} finally {
clearTimeout(timer);
}
fetch には既定のタイムアウトがありません。放置すると応答が返らないまま待ち続けるため、自前で打ち切る仕組みが要ります。中断も reject されますが、それはユーザーの操作や画面離脱の結果なので、エラー表示はせず黙って捨てるのが定石です。
データを送る場合とCORS
送信時はメソッド・ヘッダー・本文を指定します。body はオブジェクトのままでは送れず、文字列化が必要です。
await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: '山田太郎' }),
});
Content-Type: application/json を付けた別オリジンへのリクエストは、[[CORS]] のプリフライトを発生させます。そして CORS で拒否されたときの見え方は独特です。fetch は TypeError で reject し、ステータスコードすら読めません。Networkタブでは200が返っているのにJavaScriptからは何も取れない — この非対称さに気づけば、原因がコードではなくサーバーのヘッダー設定にあると判断できます。
初学者向けポイント
res.okのチェック漏れが最頻出のバグです。ok判定とJSON変換をまとめたラッパー関数を1つ作り、全ての呼び出しをそこに通すのが実務での定番です- レスポンスの本文は1回しか読めません。
res.json()を2度呼ぶとエラーになります - 通信は目で見るのが近道です。開発者ツールのNetworkタブで [[HTTP]] のやり取りを開き、コードと突き合わせてください
関連技術とのつながり
- [[JavaScript]] — fetchが返すPromiseとasync/awaitがこのAPIの前提
- [[REST API]] — fetchが叩く相手。URLとメソッドの設計はそちら側
- [[JSON]] —
res.json()とJSON.stringify()で相互変換するデータ形式 - [[CORS]] — 別オリジンへのfetchを制限する仕組み。エラーの見え方が特殊
- [[状態管理]] — ローディング・エラー・データの3状態をどこに置くか
- [[SPAとMPA]] — SPAの「データだけをやり取りする」を実現する手段
Q: サーバーが 404 を返したとき、fetchのPromiseはどうなる?
- [ ] rejectされ、catchブロックに入る
- [x] rejectされず成功扱いになるため、res.ok を自分で確認する必要がある
- [ ] 自動的に3回リトライされる
解説: fetchは通信自体が成立すれば404でも500でもrejectしません。res.ok や res.status を毎回確認する必要があります。
Q: `await fetch(...)` と `await res.json()` で待っている対象の違いは?
- [x] 前者はステータスとヘッダーの到着、後者は本文の受信とJSONへの解釈の完了
- [ ] 前者はDNS解決、後者はTCP接続の確立
- [ ] 前者はGET、後者はPOSTの完了
解説: ヘッダーが先に読めるため、本文をすべてダウンロードする前に404などを判断して打ち切れます。
Q: AbortControllerでfetchを中断したときの扱いとして本文が定石としたのはどれ?
- [ ] 画面にエラーメッセージを表示して再読み込みを促す
- [ ] 同じリクエストを自動で送り直す
- [x] err.name が 'AbortError' なら失敗ではないので、表示せず黙って捨てる
解説: 中断はユーザーの操作や画面離脱の結果であり、通信の失敗ではないためエラー表示はしません。