Building a Debounced Autocomplete From Scratch: The Race Condition Everyone Ships
The naive autocomplete is onChange={e => fetch(...).then(setResults)} — and it has the single most commonly-shipped async-UI bug: the out-of-order response race, where the results shown don't match what's typed because a slower earlier request resolves after a faster later one. A production autocomplete has to solve four hard things at once: debounce the input (don't fire per keystroke), cancel superseded requests (fix the race at its source), handle keyboard navigation (arrow keys, Enter, Escape — it's mouse-only otherwise), and implement the ARIA combobox pattern (or it's invisible to screen readers). Get any wrong and it's broken for real users while looking fine in the demo. The build in one sentence: a debounced query drives an AbortController-cancelled fetch into an explicit idle | loading | success | error state machine, rendered as an ARIA combobox with full keyboard control.
What Makes This Hard
| Hard part | Naive version | What breaks |
|---|---|---|
| request volume | fetch per keystroke | 6 requests for "laptop", 5 unwanted |
| out-of-order race | render whatever returns last | slow "lap" overwrites fast "laptop" → wrong results |
| keyboard nav | mouse-only | can't arrow through / select with keyboard |
| ARIA | plain <div>s | silent + invisible to screen readers |
| stale closures | reading state in async callbacks | rendering results for an old query |
GIF via GIPHY
See ARCHITECTURE/decisions/37 for the why behind these decisions; this is the how.
1. The Debounce Hook
Debouncing waits for a pause in typing before acting — so "laptop" typed fast produces one query, not six. The cleanest form is a hook returning a debounced copy of a value:
import { useEffect, useState } from "react";
/** Returns `value` after it has stopped changing for `delay` ms. */
export function useDebouncedValue<T>(value: T, delay = 300): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
// Cleanup cancels the pending timeout whenever `value` changes again,
// so only a genuine pause (no change for `delay` ms) commits the value.
return () => clearTimeout(id);
}, [value, delay]);
return debounced;
}
GIF via GIPHY
The entire debounce lives in the cleanup: each new keystroke re-runs the effect, whose cleanup clearTimeouts the previous pending commit — so the value only "lands" after delay ms of no changes. This is debounce, not throttle: we want the final settled query, not periodic samples. (Throttle would fire on intermediate values — wrong for search.)
2. Cancellation: Fixing the Race at Its Source
Debouncing reduces requests but doesn't eliminate the race — two requests can still be in flight. The correct fix is cancelling the previous request when a new one starts, so a stale response never arrives to overwrite a newer one. AbortController + the fetch signal does exactly this, and the useEffect cleanup is the natural cancellation point:
import { useEffect, useState } from "react";
type Result = { id: string; label: string };
type SearchState =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; results: Result[] }
| { status: "error"; message: string };
function useSearch(query: string): SearchState {
const [state, setState] = useState<SearchState>({ status: "idle" });
useEffect(() => {
const trimmed = query.trim();
if (trimmed.length < 2) {
// Min query length: don't search on 0-1 chars (too broad, wasteful).
setState({ status: "idle" });
return;
}
const controller = new AbortController();
setState({ status: "loading" });
fetch(`/api/search?q=${encodeURIComponent(trimmed)}`, {
signal: controller.signal,
})
.then((res) => {
if (!res.ok) throw new Error(`Search failed (${res.status})`);
return res.json() as Promise<Result[]>;
})
.then((results) => setState({ status: "success", results }))
.catch((err: unknown) => {
// An aborted request rejects with AbortError — that's expected, ignore it.
if (err instanceof DOMException && err.name === "AbortError") return;
setState({
status: "error",
message: err instanceof Error ? err.message : "Something went wrong",
});
});
// Cleanup runs when `query` changes (or on unmount): abort the in-flight
// request. Its response can now NEVER arrive to overwrite a newer query.
return () => controller.abort();
}, [query]);
return state;
}
GIF via GIPHY
Two subtleties make this correct. First, the cleanup aborts the previous fetch — when the user types more, the effect re-runs, cleanup fires controller.abort(), and the old request is cancelled before the new one starts. So the "lap"-then-"laptop" race is impossible: aborting "lap" the moment "laptop" fires means "lap"'s response is discarded at the network layer. Second, an aborted fetch rejects with AbortError, which we must swallow (it's not a real error — it's us cancelling on purpose), or every keystroke would flash an error state.
Modeling the result as an explicit SearchState union (not isLoading/error/data booleans) means the UI renders exactly one coherent state and impossible combinations (loading and error) can't occur — the state-machine discipline from decisions/21.
3. The Combobox: Keyboard Navigation + ARIA
This is where "mouse-only demo" becomes "real component." The ARIA combobox pattern requires specific roles and attributes, and keyboard users need arrow keys, Enter, and Escape. The trick for screen-reader-correct highlighting is aria-activedescendant: focus stays on the input, but aria-activedescendant points at the "active" option's id, so the screen reader announces it without moving DOM focus.
import { useId, useRef, useState, type KeyboardEvent } from "react";
export function Autocomplete() {
const [query, setQuery] = useState("");
const [open, setOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1); // -1 = nothing highlighted
const debouncedQuery = useDebouncedValue(query, 300);
const state = useSearch(open ? debouncedQuery : "");
const listboxId = useId();
const inputRef = useRef<HTMLInputElement>(null);
const results = state.status === "success" ? state.results : [];
function optionId(index: number) {
return `${listboxId}-option-${index}`;
}
function commit(index: number) {
const chosen = results[index];
if (!chosen) return;
setQuery(chosen.label);
setOpen(false);
setActiveIndex(-1);
inputRef.current?.focus();
}
function onKeyDown(e: KeyboardEvent<HTMLInputElement>) {
switch (e.key) {
case "ArrowDown":
e.preventDefault(); // stop the caret from moving
setOpen(true);
setActiveIndex((i) => Math.min(i + 1, results.length - 1));
break;
case "ArrowUp":
e.preventDefault();
setActiveIndex((i) => Math.max(i - 1, 0));
break;
case "Enter":
if (activeIndex >= 0) {
e.preventDefault();
commit(activeIndex);
}
break;
case "Escape":
setOpen(false);
setActiveIndex(-1);
break;
}
}
return (
<div className="autocomplete">
<input
ref={inputRef}
type="text"
role="combobox"
aria-expanded={open && results.length > 0}
aria-controls={listboxId}
aria-autocomplete="list"
aria-activedescendant={
activeIndex >= 0 ? optionId(activeIndex) : undefined
}
value={query}
onChange={(e) => {
setQuery(e.target.value);
setOpen(true);
setActiveIndex(-1);
}}
onKeyDown={onKeyDown}
onFocus={() => setOpen(true)}
/>
{open && (
<ul id={listboxId} role="listbox" className="autocomplete__list">
{state.status === "loading" && (
<li className="autocomplete__status" aria-live="polite">
Searching…
</li>
)}
{state.status === "error" && (
<li className="autocomplete__status" role="alert">
{state.message}
</li>
)}
{state.status === "success" && results.length === 0 && (
<li className="autocomplete__status">
No results for “{debouncedQuery}”
</li>
)}
{results.map((r, i) => (
<li
key={r.id}
id={optionId(i)}
role="option"
aria-selected={i === activeIndex}
className={i === activeIndex ? "is-active" : undefined}
// onMouseDown (not onClick) so it fires BEFORE the input's blur,
// preventing the list from closing before the selection registers.
onMouseDown={(e) => {
e.preventDefault();
commit(i);
}}
onMouseEnter={() => setActiveIndex(i)}
>
{r.label}
</li>
))}
</ul>
)}
</div>
);
}
GIF via GIPHY
The non-obvious details that trip people up:
onMouseDownwithpreventDefault, notonClick. A click on an option would first blur the input (closing the list), and the click might never register on the now-unmounted list.onMouseDownfires before blur, andpreventDefault()stops the input from losing focus at all — so the selection reliably commits.aria-activedescendantkeeps focus on the input while pointing at the highlighted option, so arrow keys work and the screen reader announces each option — without the focus-management complexity of actually moving focus into the list.e.preventDefault()on ArrowUp/Down stops the browser from moving the text caret to the start/end of the input while you're navigating options.- Loading/error/empty are real rendered states with
aria-live="polite"(loading, announced) androle="alert"(error, announced urgently) — not a blank dropdown.
4. Closing on Outside Click
A combobox must close when you click away. A reusable useOnClickOutside handles it (attach to the wrapper div):
GIF via GIPHY
import { useEffect, type RefObject } from "react";
export function useOnClickOutside(
ref: RefObject<HTMLElement>,
handler: () => void
) {
useEffect(() => {
function onPointerDown(e: PointerEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) handler();
}
document.addEventListener("pointerdown", onPointerDown);
return () => document.removeEventListener("pointerdown", onPointerDown);
}, [ref, handler]);
}
Edge Cases & Gotchas
- The race is invisible in development. On a fast local network responses return in order, so the out-of-order bug only shows in production on real networks. Test it by artificially delaying responses (
await sleep(random)) — the cancellation fix makes it correct regardless of timing. - StrictMode double-invokes effects in dev. React 18 mounts, unmounts, and remounts each component once in dev StrictMode — which fires your effect's cleanup. If cancellation is correct (abort on cleanup), this is harmless; if you forgot cleanup, StrictMode exposes it as a doubled request. That's a feature, not a bug.
- Don't search on the selected value. After
commit,querybecomes the chosen label — which would trigger a new search for it. Gating the search onopen(useSearch(open ? debouncedQuery : "")) and closing on commit prevents the redundant round-trip. - Reset
activeIndexwhen results change. A stale highlight index can point past the end of a new, shorter result list. Reset to-1on every input change. - Consider a request cache. A query typed before (backspace then retype) should return instantly from cache rather than re-fetching — wrap
useSearchin aMap<query, results>or use React Query / SWR (which give you dedup, cancellation, and caching for free — often the right production choice over hand-rolling).
GIF via GIPHY
Key Takeaways
- The defining autocomplete bug is the out-of-order response race — fix it with
AbortControllercancellation (abort the previous request in the effect cleanup), not just debouncing. Debounce solves request volume; cancellation solves correctness. - Swallow
AbortError— an aborted fetch rejects, and that rejection is expected (you cancelled on purpose), so it must not flash an error state. - Model results as an explicit
idle | loading | success | errorstate machine, and render loading/empty/error as real states (witharia-live/role="alert"), not a blank dropdown. - Implement the ARIA combobox pattern:
role="combobox"+aria-expanded/aria-controls/aria-autocompleteon the input,role="listbox"/optionon the list, andaria-activedescendantso arrow-key highlighting is announced while focus stays on the input. - Use
onMouseDown+preventDefaulton options (notonClick) so selection commits before the input blurs and closes the list. - In production, prefer React Query / SWR — they give you debounced/deduplicated, cancelled, cached fetching (the whole hard part) so you don't reimplement the race.
GIF via GIPHYWhat did you think?