Autocomplete looks simple but is one of the most instructive components to build well: a single small widget packs in network coordination, race conditions, caching, and accessibility. We'll work through it with the RADIO framework — Requirements, Architecture, Data model, Interface, Optimizations — a repeatable way to reason about any front-end design.
Don't start coding yet. The most valuable habit in front-end design is to scope the problem first. Autocomplete sounds trivial, but the requirements change the design substantially.
Before designing, what should you nail down?
For this walkthrough we'll build the interesting version: a remote API with variable latency, single-select, ~10 results, light caching, and full accessibility. That's the version that surfaces the genuinely hard front-end problems.
Functional vs non-functional
Split requirements explicitly. Functional: type → see suggestions, navigate with the keyboard, select an item. Non-functional: feels instant (perceived performance), never shows results for the wrong query, accessible, resilient to flaky networks.
Keep the component headless-friendly: a small tree of presentational components driven by one hook that owns the logic. This keeps it reusable, testable, and easy to reason about.
Click a node to see what it owns. Logic lives in the hook; the components stay simple and presentational.
Does: Composition root. Wires the hook to the presentational pieces and exposes the public props.
State: none (delegates to useAutocomplete)
Does: All the logic: debounced fetching, race-safe state updates, caching, and keyboard navigation.
State: query, results, status (idle/loading/error), activeIndex, open
Returning state + handlers from a hook keeps the UI swappable and the logic unit-testable in isolation.
Does: Controlled text input. Emits keystrokes and forwards keyboard events (arrows/Enter/Escape).
State: none (controlled by the hook)
Does: role=listbox container. Renders results and shows loading/empty/error states.
State: none
Where should the component's state live?
Pick when: The default for a self-contained widget — state is only relevant to this component.
Two pieces of state matter: the current view (query, results, status, active index) and the cache of past queries. Modelling the cache as a map keyed by the normalised query string lets repeat queries return instantly.
Client-side shape (the network response shape can be richer).
How aggressively should we cache results?
Pick when: Good default: instant repeat queries (e.g. backspacing) with near-zero complexity.
For a reusable component, the public API is the most important design decision. Aim for sensible defaults, presentational escape hatches (so it's reusable), and an injectable data source (so it's testable and not tied to one backend).
A small, composable prop surface.
Note fetchSuggestions is injected rather than hard-coded — the component doesn't care where data comes from, which makes it reusable and lets tests pass a fake. renderItem keeps it headless: consumers control appearance.
This is where most of the real engineering lives. The four front-end problems that define a good autocomplete are: debouncing, out-of-order responses, request cancellation, and accessibility.
The signature insight: out-of-order responses
Type a then ap quickly. Request A (a) and request B (ap) are now both in flight. If A is slower than B, A's response arrives last and overwrites B — so the user sees results for a while the box says ap. Debouncing reduces but does not eliminate this; network latency is unpredictable. You must explicitly ensure the latest query wins.
The fix is a monotonic request id (or AbortController): tag each request, and when a response returns, ignore it unless it's the most recent. The demo below does exactly this — and invites you to break it.
Race-safe autocomplete (editable)
The API latency is randomised, so responses really do arrive out of order. Edit the code and watch the preview.
Other optimizations worth knowing:
AbortController so stale requests don't even finish — saves bandwidth on top of the latest-wins guard.Accessibility is part of building this correctly, not an add-on. Implement the WAI-ARIA combobox pattern: arrow keys move a virtual focus, Enter selects, Escape closes, and aria-activedescendant tells the screen reader which option is active — all while keeping real focus in the input.
The combobox ARIA wiring.
Self-check: a production-ready autocomplete covers…
0 / 9 covered
Going deeper (tap to reveal)