Design a search autocomplete. Apply RADIO; call out debouncing, race handling, caching, and a11y.
Debounce, request cancellation, out-of-order response handling, caching, and the combobox a11y pattern.
Requirements: as-you-type suggestions that are fast and CURRENT (no stale result overwriting a newer one), keyboard-navigable, accessible, and resilient to a slow API. Architecture: the input drives a debounced query; each query cancels the previous in-flight request; results render in a listbox the input controls. Data model: { query, results, activeIndex, loading } plus a cache mapping query→results. Interface: GET /suggest?q=… returning ranked items. Optimizations: DEBOUNCE input (~150–300ms) so you fetch per pause, not per keystroke; CANCEL the superseded request (AbortController) AND guard against out-of-order responses by ignoring any response that isn't for the latest query — this race is the #1 autocomplete bug (an earlier, slower response clobbers a newer one); CACHE by query string (reuse prefixes) so backtracking is instant; cap and rank results; skip requests for empty or trivially short input. Accessibility is a spec, not a nicety — the combobox pattern: role=combobox on the input, aria-expanded, aria-activedescendant pointing at the highlighted option, ↑/↓ to move, Enter to select, Esc to close, each option role=option. Show loading/empty/error states. Why cancel AND a latest-query guard together: cancellation frees the network, but a cancel can land after a response has already started, so you still must gate on 'is this the current query?' — sequencing by the query value (or a request id) is what actually prevents flicker.
Search boxes, @mention pickers, address/command palettes — any type-to-search with a network backend.
function Autocomplete({ search }) {
const [q, setQ] = React.useState('');
const [results, setResults] = React.useState([]);
const latest = React.useRef(0);
React.useEffect(() => {
if (!q) { setResults([]); return; }
const id = ++latest.current; // sequence this request
const ctrl = new AbortController();
const t = setTimeout(async () => { // debounce
const data = await search(q, ctrl.signal);
if (id === latest.current) setResults(data); // ignore stale responses
}, 200);
return () => { clearTimeout(t); ctrl.abort(); };
}, [q, search]);
return (
<div>
<input role="combobox" aria-expanded={results.length > 0}
value={q} onChange={(e) => setQ(e.target.value)} />
<ul role="listbox">
{results.map((r) => <li role="option" key={r.id}>{r.label}</li>)}
</ul>
</div>
);
}