JavaScript実践 #3 fetch と非同期 UI
#2 イベント処理と委譲 でユーザー入力を受け取る道具を見ました。今回は、その入力に応じて外部データを取得して画面に反映する場面です。非同期 UI の実践パターンを整理します。
最もシンプルな形から #
<button id="load">ユーザーを取得</button>
<ul id="user-list"></ul>const button = document.querySelector('#load');
const list = document.querySelector('#user-list');
button.addEventListener('click', async () => {
const res = await fetch('/api/users');
const users = await res.json();
list.innerHTML = '';
for (const user of users) {
const li = document.createElement('li');
li.textContent = user.name;
list.append(li);
}
});中級 #6 fetch API + 実践 #1 DOM 操作 の組み合わせ。動作はしますが、実際の UI ではローディング表示、エラー処理、二重クリック防止が抜けています。
ローディングとエラー — 3 つの状態 #
データのローディングには通常、次の状態があります。
- idle — まだ呼んでいない
- loading — 進行中
- success — データあり
- error — 失敗
これを UI に反映するパターンです。
button.addEventListener('click', async () => {
list.innerHTML = '<li class="loading">読み込み中...</li>';
button.disabled = true;
try {
const res = await fetch('/api/users');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const users = await res.json();
list.innerHTML = '';
for (const user of users) {
const li = document.createElement('li');
li.textContent = user.name;
list.append(li);
}
} catch (err) {
list.innerHTML = `<li class="error">エラー: ${err.message}</li>`;
} finally {
button.disabled = false;
}
});3 つのことが追加されました。
- ローディング表示 —
<li class="loading">読み込み中...</li> - エラーメッセージ —
<li class="error">... - ボタンの無効化 —
button.disabled = trueで二重クリック防止、finallyで復元
finally ブロックがポイントです。成功 / 失敗に関わらずボタンの復元が保証されます。
検索ボックス — リアルタイム入力に反応 #
検索オートコンプリートのような場面。入力するたびに fetch を呼ぶと、リクエストが多くなりすぎます。
<input id="search" placeholder="検索...">
<ul id="results"></ul>最もシンプルな(ただし非効率な)バージョン #
input.addEventListener('input', async (e) => {
const q = e.target.value;
if (!q) {
results.innerHTML = '';
return;
}
const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`);
const items = await res.json();
// ... DOM 更新
});問題が二つ:
- ‘h’、‘he’、‘hel’、…、‘hello’ とキーごとに fetch — トラフィックの無駄
- ‘h’ のレスポンスが遅れて ‘hello’ の結果を上書きしてしまうことがある
デバウンス — 連続入力を一度にまとめる #
function debounce(fn, delay) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
}
const onSearch = debounce(async (q) => {
if (!q) {
results.innerHTML = '';
return;
}
const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`);
const items = await res.json();
// ... DOM 更新
}, 300);
input.addEventListener('input', (e) => onSearch(e.target.value));上級 #1 クロージャ で見たパターンです。入力が止まってから 300ms 経たないと実際の呼び出しは行われません。素早くタイピングすれば一度だけ送られます。
AbortController — 遅いレスポンスを遮断 #
デバウンスだけでは二つ目の問題(遅いレスポンスが新しい結果を上書きする)を防げません。前の fetch をキャンセルする必要があります。
let currentController = null;
const onSearch = debounce(async (q) => {
if (!q) {
results.innerHTML = '';
return;
}
// 前のリクエストをキャンセル
if (currentController) currentController.abort();
currentController = new AbortController();
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`, {
signal: currentController.signal,
});
const items = await res.json();
// ... DOM 更新
} catch (err) {
if (err.name === 'AbortError') return; // キャンセルされたリクエストは無視
console.error(err);
}
}, 300);
input.addEventListener('input', (e) => onSearch(e.target.value));新しいリクエストが来ると前のリクエストを abort。遅れて来たレスポンスは AbortError になり、catch でフィルタされます。
この二つの道具(デバウンス + AbortController)が検索 UI の標準的な組み合わせです。
AbortSignal.timeout — タイムアウトまで
#
リクエストが時間がかかりすぎたら自動キャンセル。
const res = await fetch(url, {
signal: AbortSignal.timeout(5000),
});中級 #6 で見た道具。デバウンス / AbortController と組み合わせれば、より堅牢な検索 UI を作れます。
二つの signal を合わせる — AbortSignal.any
#
タイムアウト + ユーザーキャンセルの両方を扱いたいなら。
const userController = new AbortController();
const signal = AbortSignal.any([
userController.signal,
AbortSignal.timeout(5000),
]);
const res = await fetch(url, { signal });
// userController.abort() または 5 秒超過のいずれかでキャンセル
ES2024 の AbortSignal.any が最新の標準的な道具です。
重複リクエスト防止 — インフライトキャッシュ #
同じデータを複数の箇所から同時にリクエストするとき、一度だけ送られるようにします。
const inflight = new Map();
function fetchOnce(url) {
if (inflight.has(url)) {
return inflight.get(url);
}
const promise = fetch(url)
.then((r) => r.json())
.finally(() => inflight.delete(url));
inflight.set(url, promise);
return promise;
}
// 同時に二箇所から呼ぶ
const a = fetchOnce('/api/me');
const b = fetchOnce('/api/me');
// 実際の fetch は 1 回だけ、a と b は同じ Promise を共有
Map に進行中の Promise を入れておき — 同じ URL を再度リクエストすればその Promise をそのまま返します。TanStack Query のようなライブラリが内部的にやっていることの一つです。
ページネーション / 無限スクロールパターン #
let page = 1;
async function loadMore() {
loadMoreBtn.disabled = true;
const res = await fetch(`/api/posts?page=${page}`);
const posts = await res.json();
for (const p of posts) {
const li = document.createElement('li');
li.textContent = p.title;
list.append(li);
}
page++;
loadMoreBtn.disabled = false;
if (posts.length === 0) {
loadMoreBtn.remove(); // もう無ければボタンを削除
}
}
loadMoreBtn.addEventListener('click', loadMore);
loadMore(); // 最初に 1 回
無限スクロール — IntersectionObserver #
スクロールが終わりに近づくと自動でロード。
const sentinel = document.querySelector('.sentinel');
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
loadMore();
}
});
observer.observe(sentinel);.sentinel はリストの末尾に置いた空の要素。それが画面に入るとコールバックが実行されます。古い scroll イベントで直接座標計算をしていたパターンよりずっと効率的です。
楽観的更新 (Optimistic Update) #
サーバーのレスポンスを待たずに UI を先に更新するパターン。即時のフィードバックを与えるのに役立ちます。
likeBtn.addEventListener('click', async () => {
// 楽観的に即座に UI を変更
likeBtn.classList.add('liked');
countEl.textContent = parseInt(countEl.textContent) + 1;
try {
await fetch('/api/like', { method: 'POST' });
} catch (err) {
// 失敗したら戻す
likeBtn.classList.remove('liked');
countEl.textContent = parseInt(countEl.textContent) - 1;
alert('いいね失敗');
}
});成功が圧倒的に多いアクション(いいね、ブックマーク)に向いています。失敗が頻繁に起こる場面には不向き — 戻すときのちらつきが目障りです。
エラー処理の段階 #
1) ネットワーク段階 #
try {
const res = await fetch(url);
// ...
} catch (err) {
if (err.name === 'TypeError') {
// ネットワーク自体がダメ (オフライン、DNS など)
showOffline();
} else if (err.name === 'AbortError') {
// キャンセル
return;
} else {
throw err;
}
}2) HTTP ステータス #
if (!res.ok) {
if (res.status === 401) return redirectToLogin();
if (res.status === 404) return showNotFound();
if (res.status >= 500) return showServerError();
throw new Error(`HTTP ${res.status}`);
}3) レスポンスボディのパース / 検証 #
const data = await res.json();
if (!data || typeof data.id !== 'string') {
throw new Error('不正なレスポンス形式');
}3 つの段階すべて状況が違い、処理も違います。小さなラッパー関数で整理すれば、すべての呼び出し箇所がスッキリします。
小さなラッパー — 自分のアプリの fetch 約束事 #
export async function api(url, options = {}) {
let res;
try {
res = await fetch(url, {
headers: { 'Content-Type': 'application/json', ...options.headers },
...options,
});
} catch (err) {
if (err.name === 'AbortError') throw err;
throw new Error(`ネットワークエラー: ${err.message}`);
}
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
return res.json();
}
export const apiGet = (url, signal) => api(url, { signal });
export const apiPost = (url, data, signal) => api(url, {
method: 'POST',
body: JSON.stringify(data),
signal,
});このくらい作っておくだけで、呼び出し箇所はとても綺麗になります。
import { apiGet } from './api.js';
try {
const users = await apiGet('/api/users');
// ...
} catch (err) {
showError(err.message);
}まとめ #
この記事で整理した内容:
- ローディング / エラー / 成功の 3 状態を UI に反映
try/catch/finally+disabledトグルで二重クリック防止- デバウンスで入力頻度を減らす
- AbortController で遅いレスポンスをキャンセル
AbortSignal.timeout/AbortSignal.anyでタイムアウトを合成- インフライトキャッシュで重複リクエストを防ぐ
- IntersectionObserver で無限スクロール
- 楽観的更新パターン
- エラーはネットワーク / ステータス / ボディの 3 段階
- 小さなラッパー関数で呼び出し箇所を整理
次の記事(#4 フォームの扱い)ではフォーム入力の検証、FormData の活用、そして送信時点のパターンを扱います。