検索欄へ「a」、続けて「ab」と入力したのに、最後は「a」の検索結果が表示される。この問題は、リクエストを送った順と、応答が届く順が違うと起こります。新しい処理を始めても、古い処理の続きが自動で無効になるわけではありません。
対策は、入力に連番を付け、最新の番号を持つ処理だけに画面を更新させることです。AbortControllerで不要な通信も中止します。成功したデータだけでなく、エラーと「読み込み終了」の更新にも同じ判定を入れます。
「a」が遅く、「ab」が速い状況を作る
通信速度が偶然逆転するのを待たず、1文字なら700ms、2文字以上なら100ms待つ関数を使います。これは応答順を確認するための疑似検索です。APIへの通信や、検索速度の測定は行っていません。
fake-search.jsとして保存してください。この関数は意図的にAbortSignalを受け取らず、中止できない処理として動きます。
// Intentionally ignores AbortSignal, like non-cancellable post-processing.
export function fakeSearch(query) {
const delay = query.length === 1 ? 700 : 100;
return new Promise((resolve) => {
setTimeout(() => resolve([`${query} の検索結果`]), delay);
});
}
| 順番 | 起こること | 届いた順に表示すると |
|---|---|---|
| 1 | 「a」を検索。700ms待つ | まだ結果がない |
| 2 | すぐに「ab」を検索。100ms待つ | まだ結果がない |
| 3 | 「ab」の結果が先に届く | abを表示 |
| 4 | 「a」の結果が後から届く | aで上書きしてしまう |
実際のfetchでも、サーバー処理、キャッシュ、通信状態などによって完了順が変わります。async/awaitに書き換えても、別々に開始した検索同士の順序は保証されません。非同期処理の実行順そのものは、JavaScriptの同時実行モデルで確認できます。
最新の入力だけが、結果・エラー・終了状態を反映できるようにする
次のlatest-search.jsは、検索処理のloadと表示処理のrenderを受け取ります。loadは結果の配列を返すPromise、renderはその時点の状態を画面へ反映する関数です。入力が変わったらupdate()を呼びます。
export function createLatestSearch(load, render) {
let version = 0;
let controller = null;
let disposed = false;
async function update(input) {
if (disposed) return;
const query = input.trim();
const myVersion = ++version;
controller?.abort();
controller = null;
if (!query) {
render({ query: '', items: [], error: null, loading: false });
return;
}
const mine = new AbortController();
controller = mine;
const isCurrent = () => !disposed && myVersion === version;
const state = { query, items: [], error: null, loading: true };
render({ ...state });
try {
const items = await load(query, { signal: mine.signal });
if (!isCurrent()) return;
state.items = items;
} catch (error) {
if (!isCurrent() || mine.signal.aborted) return;
state.error = error instanceof Error ? error.message : '取得失敗';
} finally {
if (isCurrent()) {
controller = null;
render({ ...state, loading: false });
}
}
}
function dispose() {
disposed = true;
version += 1;
controller?.abort();
controller = null;
}
return { update, dispose };
}
myVersionは今回の検索が始まったときの番号、versionは最新の番号です。後から別の入力が来れば一致しなくなり、古い処理はisCurrent()で止まります。番号を進めるのは、空文字かどうかの判定より前です。入力を消しただけでも、以前の結果を無効にする必要があるためです。
成功時のstate.itemsだけ守っても十分ではありません。古いエラーで最新の結果を消したり、古い処理のfinallyで最新の「読み込み中」を終わらせたりすることもあります。このコードでは、catchとfinallyにも同じ判定を置いています。
検索文字列そのものの比較だけにはしていません。「a→b→a」と入力した場合、最初と最後は同じ文字でも別の操作です。最初のaに対する古い応答を、最後のaの結果として採用しないために番号を使います。
比較用のHTMLで、修正前と修正後を同時に見る
同じフォルダへindex.htmlを保存します。入力欄は1つ、結果欄は2つです。「届いた順に表示」は問題を再現するため、あえて古い結果も受け取ります。「最新の入力だけ反映」が修正後です。
<!doctype html>
<html lang="ja">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>検索結果の応答順を比べる</title>
<style>
body { max-width: 760px; margin: auto; padding: 20px; font: 16px/1.7 sans-serif; color: #243147; }
input { box-sizing: border-box; width: 100%; padding: 12px; font: inherit; }
section { border: 1px solid #b6c4d4; border-radius: 12px; margin-top: 20px; padding: 16px; }
p { overflow-wrap: anywhere; }
</style>
<h1>検索結果の応答順を比べる</h1>
<p>「a」と入力し、0.6秒以内に「ab」へ変えてください。両方の処理が終わると、結果が違います。</p>
<label for="query">検索語</label>
<input id="query" autocomplete="off">
<section><h2>届いた順に表示</h2><p id="unsafe" aria-live="polite">未入力</p></section>
<section><h2>最新の入力だけ反映</h2><p id="safe" aria-live="polite">未入力</p></section>
<script type="module">
import { createLatestSearch } from './latest-search.js';
import { fakeSearch } from './fake-search.js';
const input = document.querySelector('#query');
const unsafe = document.querySelector('#unsafe');
const safe = document.querySelector('#safe');
const search = createLatestSearch(fakeSearch, (state) => {
safe.textContent = state.loading ? `${state.query} を検索中` :
state.error ?? (state.items.join('、') || '未入力・結果なし');
safe.setAttribute('aria-busy', String(state.loading));
});
input.addEventListener('input', () => {
const query = input.value.trim();
if (!query) unsafe.textContent = '未入力';
else fakeSearch(query).then((items) => { unsafe.textContent = items.join('、'); });
void search.update(query);
});
window.addEventListener('pagehide', () => search.dispose(), { once: true });
window.addEventListener('pageshow', (event) => {
if (event.persisted) location.reload();
});
</script>
</html>
3ファイルを置いたフォルダで、例えばPython 3のHTTPサーバーを起動します。ファイルをダブルクリックする方法ではなく、HTTPのURLで開いてください。
python3 -m http.server 4176 --bind 127.0.0.1
http://127.0.0.1:4176/を開き、「a」と入力した後、0.6秒以内に「ab」へ変えます。両方の処理が終わったとき、上はa、下はabになれば逆転と対策を確認できています。次は1文字を入力してすぐ消し、修正後の欄に古い結果が戻らないことも確かめましょう。
表示にはtextContentを使い、結果の文字列をHTMLとして解釈させていません。pagehideではdispose()で後続更新を止めます。このデモはブラウザの戻る操作でページがキャッシュから復元された場合、再読み込みして検索処理を作り直す構成です。
実際のfetchへつなぐときは、通信の中止と表示の無効化を併用する
APIが{"items":["結果1","結果2"]}というJSONを返すなら、取得側を次のapi-search.jsへ差し替えられます。/api/searchは利用するバックエンドのURLに合わせてください。先ほどのPythonの静的HTTPサーバーには、このAPIはありません。
export async function apiSearch(query, { signal }) {
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
signal,
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
if (!data || !Array.isArray(data.items) || data.items.some((item) => typeof item !== 'string')) {
throw new Error('検索結果の形式が違います');
}
return data.items;
}
index.htmlではfakeSearchのimportをimport { apiSearch } from './api-search.js';に変え、createLatestSearch(fakeSearch, ...)の第1引数をapiSearchにします。比較用の上側のsectionとconst unsafe = ...の行を削除し、入力イベントの登録全体を次の3行へ置き換えます。
input.addEventListener('input', () => {
void search.update(input.value);
});
AbortController.abort()は、signalを渡したfetchやレスポンス本文の読み取りを中止できます。適用対象はAbortControllerのリファレンスでも確認できます。一方、自作の遅延Promiseや、signalを扱わない後処理まで自動で止まるわけではありません。
| 手段 | 担当すること | 残る確認 |
|---|---|---|
| AbortController | 中止可能な通信や本文読み取りを止める | 中止に対応しない後処理の反映を防ぐ |
| 連番ID | 今の入力に対応する処理だけ反映する | 通信自体を減らすなら別途中止する |
| 最新Promiseとの比較 | 結果が、保持している最新のPromiseに属するか見る | 入力クリアや破棄時にも最新の参照を無効にする |
最新Promiseを保存して比較する方法も、反映してよい処理を見分けるという点では同じです。この例では、リクエストを送らない空入力も1回の変更として扱いやすい連番を選んでいます。HTTPエラーやJSON取得の基本は、fetchでJSONを取得する方法を参照してください。
入力が止まるまで待つdebounceを加えるときも、古い表示を無効にする時点は入力変更時にします。update()全体をただ遅延させると、その待ち時間の間に古い結果が届いて反映されます。このデモはdebounceを使わず、入力イベントごとに番号を進めています。
ReactとVueでは、取得処理を持つ場所でcleanupする
ReactでuseEffectから取得する場合、依存する検索語が変わるときと、コンポーネントが外れるときのcleanupで、そのEffectが始めた処理を無効にします。React公式の取得例も、cleanupで古い結果を無視する方法を示しています。フレームワーク側に取得・キャッシュ管理があるなら、その仕組みに合わせる選択肢もあります。
この例の関数をEffectの実行ごとに作るなら、その回のcleanupでdispose()します。破棄したインスタンスは再利用できないため、次のEffectでは新しく作ります。描画のたびに無関係なインスタンスを作り続ける構成にはしません。
Vueのwatchでは、コールバック第3引数のonCleanupへ、今回の処理を無効にする関数を登録できます。Vue公式の副作用のクリーンアップで、古いリクエストを中止する例と登録方法が説明されています。どちらでも、取得データだけでなくエラー・読み込み終了の更新まで、同じ有効範囲に揃えます。
APIがデータを更新するPOSTやPUTの場合は別の確認も要ります。ブラウザが古い結果を捨てても、サーバーで始まった更新の取り消しや順序は保証されません。この表示制御を、更新競合を防ぐ仕組みとして流用しないでください。
成功の逆転だけでなく、古い失敗と入力クリアでも試す
今回のコードはNode.jsの6テストに加え、Chromeで実際のHTTP応答を遅らせて確認しています。自分の画面へ組み込んだ後も、次の条件を残して試してください。
| 試す順序 | 確認する結果 |
|---|---|
| 新しい成功→古い成功 | 新しい結果が残る |
| 新しい検索中に古い処理が失敗 | 古いエラーを出さず、新しい読み込み中を保つ |
| 検索中に入力を空にする | 古い結果が後から戻らない |
| a→b→aへ変え、最初のaが最後に完了 | 最後の操作の結果が残る |
| 最新の検索で500や不正なデータ形式 | 最新のエラーを表示し、読み込み中が終わる |
| 通信中・本文読み取り中に次へ進む | 中止された古い処理が表示を書き換えない |
| 表示領域を破棄してから古い処理が完了 | 破棄済みの画面への反映を行わない |
「中止されたから大丈夫」ではなく、renderや状態更新の直前で、この結果は今の入力に属するかを確かめるのがポイントです。その条件を成功・失敗・終了へ揃えると、応答順が変わっても画面の意味を保てます。