JavaScript実践 #3 fetch と非同期 UI

#2 イベント処理と委譲 でユーザー入力を受け取る道具を見ました。今回は、その入力に応じて外部データを取得して画面に反映する場面です。非同期 UI の実践パターンを整理します。

最もシンプルな形から #

HTML
<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 つのことが追加されました。

  1. ローディング表示<li class="loading">読み込み中...</li>
  2. エラーメッセージ<li class="error">...
  3. ボタンの無効化button.disabled = true で二重クリック防止、finally で復元

finally ブロックがポイントです。成功 / 失敗に関わらずボタンの復元が保証されます。

検索ボックス — リアルタイム入力に反応 #

検索オートコンプリートのような場面。入力するたびに fetch を呼ぶと、リクエストが多くなりすぎます。

HTML
<input id="search" placeholder="検索...">
<ul id="results"></ul>

最もシンプルな(ただし非効率な)バージョン #

キー入力ごとに fetch
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 更新
});

問題が二つ:

  1. ‘h’、‘he’、‘hel’、…、‘hello’ とキーごとに fetch — トラフィックの無駄
  2. ‘h’ のレスポンスが遅れて ‘hello’ の結果を上書きしてしまうことがある

デバウンス — 連続入力を一度にまとめる #

debounce 関数
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 をキャンセルする必要があります。

AbortController で前のリクエストをキャンセル
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 #

タイムアウト + ユーザーキャンセルの両方を扱いたいなら。

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 #

スクロールが終わりに近づくと自動でロード。

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 約束事 #

api.js
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 の活用、そして送信時点のパターンを扱います。

X