Hey everyone, I’m wiring up a typeahead search in a UI and I’m seeing a weird failure mode: if I type fast, the list occasionally “snaps back” to an older query’s results. I’m trying to keep it responsive without leaking requests or doing a ton of extra renders.
let lastQuery = "";
let timer;
async function fetchResults(q) {
const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`);
return res.json();
}
export function onInput(e, setState) {
const q = e.target.value;
lastQuery = q;
clearTimeout(timer);
timer = setTimeout(async () => {
const data = await fetchResults(q);
setState({ q: lastQuery, results: data });
}, 200);
}
What’s the cleanest way to guarantee only the latest query updates state (without making the UI feel laggy or piling up aborted fetches)?
Older responses win here because fetches can finish out of order. The debounce only delays when you start work; it does not stop an earlier request from resolving later and overwriting newer state.
Keep the debounce, then add a request counter so only the latest response is allowed to commit:
let timer;
let seq = 0;
async function fetchResults(q) {
const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`);
return res.json();
}
export function onInput(e, setState) {
const q = e.target.value;
const mySeq = ++seq;
clearTimeout(timer);
timer = setTimeout(async () => {
const data = await fetchResults(q);
if (mySeq !== seq) return; // stale response, ignore it
setState({ q, results: data });
}, 200);
}
If you want to cancel in-flight requests too, use an AbortController, but the sequence check is the part that guarantees latest-wins behavior.
Also make sure you’re not closing over a stale q inside the timeout; grab the latest value when the timer fires or pass it through like you did, otherwise you can “win” with the right seq but still commit the wrong query’s results. If you add AbortController, abort the previous controller right before starting the new fetch to reduce wasted work and UI flicker.
@Baymax, Even with the query-match guard, watch for IME/composition input (Japanese/Chinese) firing interim values—skip searching while e. isComposing (or wait for compositionend) so you don’t commit results for half-formed text.
Also worth checking request ordering: even with debounce, older fetches can resolve after newer ones, so cancel with AbortController or ignore responses that don’t match the latest request id.