Day 56: Build: Custom Autocomplete / Typeahead Component
Build a fully accessible, debounced, keyboard-navigable autocomplete from scratch — a machine-coding interview staple.
Study
Concepts
The requirements that make this deceptively hard
A correct autocomplete needs: debounced input (Day 13's dynamic-window instincts apply — do not fire a request per keystroke), race-condition-safe async handling (a slow response for an OLD query must not overwrite a newer query's results — this is the exact "stale response" problem from Day 24/28), full keyboard navigation (ArrowUp/Down to move a highlighted option, Enter to select, Escape to close), and correct ARIA roles (`combobox`, `listbox`, `option`, `aria-activedescendant`) so screen reader users get an equivalent experience.
This single component is a genuine synthesis of Month 1's async/closure fundamentals with real UI engineering — it is one of the most common "build X" interview prompts precisely because it touches so many fundamentals at once.
See It
Visualizations
Visualization
Keystroke to rendered results
local input state updates instantly
wait for typing to pause before firing a request
lets late responses be discarded safely
Build It
Code Examples
A complete, accessible, race-safe autocomplete
function Autocomplete({ fetchSuggestions, onSelect }) {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [highlighted, setHighlighted] = useState(-1);
const [isOpen, setIsOpen] = useState(false);
const requestIdRef = useRef(0);
useEffect(() => {
if (!query) { setResults([]); return; }
const timeoutId = setTimeout(async () => {
const thisRequestId = ++requestIdRef.current; // tag this request
const data = await fetchSuggestions(query);
if (thisRequestId !== requestIdRef.current) return; // a newer query won the race — discard
setResults(data);
setHighlighted(-1);
setIsOpen(true);
}, 300);
return () => clearTimeout(timeoutId); // cancel the pending debounce on every keystroke
}, [query, fetchSuggestions]);
function handleKeyDown(e) {
if (!isOpen) return;
if (e.key === 'ArrowDown') { e.preventDefault(); setHighlighted((i) => Math.min(i + 1, results.length - 1)); }
if (e.key === 'ArrowUp') { e.preventDefault(); setHighlighted((i) => Math.max(i - 1, 0)); }
if (e.key === 'Enter' && highlighted >= 0) { selectItem(results[highlighted]); }
if (e.key === 'Escape') { setIsOpen(false); }
}
function selectItem(item) {
onSelect(item);
setQuery(item.label);
setIsOpen(false);
}
return (
<div role="combobox" aria-expanded={isOpen} aria-haspopup="listbox">
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
aria-activedescendant={highlighted >= 0 ? `option-${highlighted}` : undefined}
aria-autocomplete="list"
/>
{isOpen && (
<ul role="listbox">
{results.map((item, i) => (
<li
key={item.id}
id={`option-${i}`}
role="option"
aria-selected={i === highlighted}
onMouseEnter={() => setHighlighted(i)}
onClick={() => selectItem(item)}
style={{ background: i === highlighted ? '#eee' : undefined }}
>
{item.label}
</li>
))}
</ul>
)}
</div>
);
}Remember
Key Takeaways
- Debounce the network call, not the input's visual update — the input must always feel instant.
- Tag every async request with an incrementing ID and discard responses that are no longer the latest — this is the general fix for stale-response races.
- Full keyboard support (Arrow keys, Enter, Escape) and correct ARIA roles are not optional polish — they are core requirements of this component.
- Reset the highlighted index whenever the result set changes, or a stale highlight can point at the wrong/nonexistent item.
- This component is a checklist interviewers use to probe several fundamentals at once — treat it as a study synthesis, not just a UI task.
Do It
Practice
- 1Extend the component to highlight the matched substring within each result label.
- 2Add a "no results found" state and a loading spinner state, keeping ARIA correct for both.
- 3Convert the debounce+race-guard logic into a reusable useDebouncedAsync(fn, delay) custom hook (ties back to Day 24).