Build an autocomplete / typeahead component
Controlled input + debounced async suggestion fetch + AbortController to drop stale responses + keyboard navigation (arrows, Enter, Esc) + ARIA combobox pattern. Cache by query string.
Autocomplete is a fan-favorite machine-coding round because it combines async (debounce + race-condition handling), state management, accessibility, and UI polish. A complete answer covers six areas.
1. Component shape. Controlled input + a popover list of suggestions. Props: fetchSuggestions(q): Promise<Item[]>, onSelect(item), optional renderItem. State: { query, suggestions, activeIndex, open, isLoading }.
2. Debounce the fetch. Without debounce, every keystroke fires a request. 200–300ms is the sweet spot — instant-feeling but cuts requests by 5–10x. Use a useDebouncedValue hook or a stable debounced callback in useMemo. Skip the fetch entirely for queries shorter than minChars (often 2).
3. Drop stale responses with AbortController. This is the key senior detail. The user types "ne", then "new", then "news". If the "ne" response arrives last, it overwrites "news" suggestions — the dreaded "results don't match query" bug. Two solutions: (a) abort the previous request when a new one starts; (b) tag each request with a sequence number and only commit if it matches the latest. (a) is cleaner because it also saves bandwidth.
4. Caching. Map query → results in a ref. Hitting backspace re-shows cached suggestions instantly without a network round-trip. Bound the cache size (LRU, ~50 entries) so it doesn't grow forever.
5. Keyboard navigation. Down/Up moves activeIndex (clamp + wrap). Enter selects suggestions[activeIndex]. Esc closes. Tab closes (let focus leave naturally). Home/End jump to first/last. The active item should scrollIntoView({ block: "nearest" }) so it stays visible.
6. Accessibility — the ARIA combobox pattern. This is what separates juniors from seniors:
- Input:
role="combobox",aria-expanded={open},aria-controls="listbox-id",aria-activedescendant={\opt-${activeIndex}\},aria-autocomplete="list". - List:
role="listbox" id="listbox-id". - Items:
role="option",id="opt-N",aria-selected={i === activeIndex}. - Live region
aria-live="polite"announces "5 suggestions available."
Don't use onBlur to close — clicking a suggestion fires blur first and the click never lands. Use onMouseDown on items (fires before blur) or manage open state explicitly with Esc / outside-click detection.
Edge cases that earn points.
- Empty query → close, don't show "no results."
- Network error → show inline retry, don't replace the cached results.
- User pastes a long string → still debounce (don't fire instantly).
- API returns identical results → don't re-render the list (compare by id, key by id).
- Selecting via mouse + active index out of sync → reset
activeIndexon hover.
When to use a library. downshift, react-aria's useComboBox, or @radix-ui/react-combobox give you a11y for free. In an interview, write the bones from scratch but mention you'd reach for one of these in production.
Code
Follow-up questions
- •How do you prevent stale responses from overwriting newer ones?
- •Why onMouseDown instead of onClick on the list items?
- •How does the ARIA combobox pattern differ from a plain dropdown?
- •How would you support multi-select tags?
Common mistakes
- •Closing the menu in onBlur — clicks on items never register.
- •No AbortController — out-of-order responses overwrite the latest.
- •No debounce or too-aggressive debounce (>500ms feels laggy).
- •Missing aria-activedescendant — screen readers don't announce navigation.
Performance considerations
- •Cache results per query — backspace becomes free.
- •Virtualize the list when result count exceeds a few hundred.
- •Memoize item renderers; key by stable id.
Edge cases
- •User types and immediately tabs away — abort in cleanup.
- •Backend returns a query that no longer matches input — drop it (sequence check).
- •Composition events (IME for CJK) — debounce only on `compositionend` or skip composition events.
Real-world examples
- •Google search box, GitHub repo finder, Algolia DocSearch — all use this exact pattern.