Autocomplete / Typeahead

mediumUI Component

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.

R

Requirements

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.

A

Architecture

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.

<Autocomplete>

Does: Composition root. Wires the hook to the presentational pieces and exposes the public props.

State: none (delegates to useAutocomplete)

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.

<SearchInput>

Does: Controlled text input. Emits keystrokes and forwards keyboard events (arrows/Enter/Escape).

State: none (controlled by the hook)

<SuggestionList>

Does: role=listbox container. Renders results and shows loading/empty/error states.

State: none

<SuggestionItem>

Where should the component's state live?

Common default

Pick when: The default for a self-contained widget — state is only relevant to this component.

PROS
  • Simplest; no external dependencies
  • Encapsulated and reusable anywhere
  • Easy to unit test the hook in isolation
CONS
  • Caching is per-instance unless you lift the cache out
D

Data Model

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).

Loading editor…

How aggressively should we cache results?

Common default

Pick when: Good default: instant repeat queries (e.g. backspacing) with near-zero complexity.

PROS
  • Eliminates duplicate requests as the user edits
  • Trivial to implement and reason about
CONS
  • Lost on reload
  • Can grow — cap it with an LRU if needed
I

Interface (Component API)

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.

Loading editor…

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.

O

Optimizations & Deep Dives

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.

Loading editor…

Other optimizations worth knowing:

  • Debounce keystrokes (~250ms) so you don't fire a request per character. Throttle instead if you want periodic updates while typing.
  • Cancellation with AbortController so stale requests don't even finish — saves bandwidth on top of the latest-wins guard.
  • Caching repeat queries (see Data Model) for instant results when backspacing.
  • `minQueryLength` to avoid firing on a single character.
  • Virtualize the list only if results can be very long (usually they're capped at ~10, so skip it).

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.

Loading editor…

Self-check: a production-ready autocomplete covers…

0 / 9 covered

Going deeper (tap to reveal)