Build a debounced search / autocomplete
Controlled input → debounced query → fetch with AbortController to cancel stale requests → results dropdown. Must handle: race conditions (out-of-order responses), loading/empty/error states, keyboard navigation (arrows/enter/escape), min query length, and accessibility (combobox ARIA).
Autocomplete is a deceptively deep component — it's debounce + cancellation + races + keyboard + accessibility all at once.
The implementation
function Autocomplete() {
const [query, setQuery] = useState("");
const [results, setResults] = useState([]);
const [status, setStatus] = useState("idle"); // idle|loading|success|error|empty
const [activeIndex, setActiveIndex] = useState(-1);
const debouncedQuery = useDebouncedValue(query, 300);
useEffect(() => {
if (debouncedQuery.length < 2) { setResults([]); setStatus("idle"); return; }
const controller = new AbortController();
setStatus("loading");
fetch(`/api/search?q=${encodeURIComponent(debouncedQuery)}`, { signal: controller.signal })
.then((r) => r.json())
.then((data) => {
setResults(data);
setStatus(data.length ? "success" : "empty");
})
.catch((e) => { if (e.name !== "AbortError") setStatus("error"); });
return () => controller.abort(); // cancel stale request
}, [debouncedQuery]);
const onKeyDown = (e) => {
if (e.key === "ArrowDown") setActiveIndex((i) => Math.min(i + 1, results.length - 1));
if (e.key === "ArrowUp") setActiveIndex((i) => Math.max(i - 1, 0));
if (e.key === "Enter" && activeIndex >= 0) select(results[activeIndex]);
if (e.key === "Escape") { setResults([]); setActiveIndex(-1); }
};
// ... render input + results list
}Everything interviewers grade
1. Debounce the query — don't fire a request per keystroke; wait for a ~300ms pause. Debounce the derived value, keep the input itself instant.
2. Cancel stale requests (race conditions) — AbortController, aborted in the effect cleanup. This kills the classic bug: you type "react", a slow response for "rea" arrives after the "react" response and overwrites it with wrong results. Debounce alone doesn't fix this — cancellation does.
3. Min query length — don't search on 0–1 chars (noise, expensive).
4. All UI states — loading spinner, results, empty ("no matches"), error (with retry). Not just the happy path.
5. Keyboard navigation — Arrow Up/Down to move the highlight, Enter to select, Escape to close. A search box that's mouse-only is incomplete.
6. Accessibility — it's a combobox: role="combobox", aria-expanded, aria-activedescendant pointing at the highlighted option, role="listbox"/role="option", aria-live for result-count announcements.
7. Other polish — click-outside to close, highlight the matching substring, cache results per query, blur/focus handling, don't show stale results from a previous query.
The framing
"It's a controlled input feeding a debounced query, which drives a fetch with an AbortController, rendering a results dropdown. The four things I'd be sure to nail: debounce the derived query so I'm not firing per keystroke; cancel stale requests in the effect cleanup — because debounce alone doesn't stop an old slow response from overwriting a newer one; handle all states including empty and error, not just results; and full keyboard support — arrows, enter, escape — plus combobox ARIA. The accessibility and the race-condition handling are what separate a real autocomplete from a toy."
Follow-up questions
- •Why doesn't debouncing alone fix out-of-order responses?
- •What ARIA roles and attributes does an autocomplete need?
- •How do you implement keyboard navigation of the results?
- •How would you cache results to avoid refetching the same query?
Common mistakes
- •Firing a request on every keystroke (no debounce).
- •Not cancelling stale requests — out-of-order responses show wrong results.
- •Only handling the success state — no empty/error/loading.
- •Mouse-only — no keyboard navigation.
- •No ARIA — inaccessible to screen readers.
Performance considerations
- •Debounce cuts request volume; cancellation avoids processing discarded responses; caching per query avoids refetching. For large result sets, virtualize the dropdown. Memoize result rows.
Edge cases
- •A slow response for an old query resolving after a newer one.
- •Query cleared while a request is in flight.
- •Zero results — distinct empty state.
- •Very fast typing; user selects with keyboard before results render.
- •Special characters in the query (must encode).
Real-world examples
- •Search bars with live suggestions (Google, e-commerce site search).
- •Address/location autocomplete, @-mention pickers.