Autocomplete Search Box
Machine-coding brief for a typeahead search input — debounce, race-safe fetch, keyboard nav, a11y, and caching.
intermediate5 min read
- machine-coding
- react
- a11y
- async
- interview
Problem statement
Build an autocomplete / typeahead search box. User types a query; the UI shows matching suggestions from a remote (or mocked) API. Interviewers score async correctness, accessibility, state design, and UX under time pressure — not pixel polish.
Requirements
Must have
- Controlled text input
- Debounced fetch (e.g. 200–300ms) after query length ≥ 2
- Dropdown list of suggestions
- Loading and empty (“no results”) states
- Click a suggestion → fill input and close list
- Keyboard: ↑/↓ move active option, Enter select, Escape close
- Accessible combobox pattern (see below)
- Cancel or ignore stale responses (race safety)
Should have
- Highlight matching substring
- Client cache for recent queries
- Error state with retry
- Click outside to close
Nice to have
- Recent searches
- Virtualized long lists
- IME composition handling (
compositionstart/compositionend)
Planning (5 minutes out loud)
- API contract —
GET /search?q=→{ id, label }[] - Debounce + AbortController
- Active index for keyboard
- ARIA roles — combobox / listbox / option
- MVP first — input + list + mouse; then keyboard + a11y
Architecture
Autocomplete
├── SearchInput // combobox input
├── SuggestionList // listbox
│ └── SuggestionItem // option
└── hooks/
useDebouncedValue
useAutocompleteSearch // fetch, cache, abort
Data model
type Suggestion = {
id: string;
label: string;
};
type Status = "idle" | "loading" | "success" | "error" | "empty";
Component API
type AutocompleteProps = {
placeholder?: string;
minChars?: number; // default 2
debounceMs?: number; // default 250
search: (query: string, signal: AbortSignal) => Promise<Suggestion[]>;
onSelect: (item: Suggestion) => void;
maxResults?: number;
};
Keep search injected so the interviewer can swap mock vs real API without rewriting the component.
Implementation sketch
Debounced query
function useDebouncedValue<T>(value: T, ms: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = window.setTimeout(() => setDebounced(value), ms);
return () => window.clearTimeout(id);
}, [value, ms]);
return debounced;
}
Race-safe search
function useAutocompleteSearch(
query: string,
search: AutocompleteProps["search"],
minChars: number
) {
const [items, setItems] = useState<Suggestion[]>([]);
const [status, setStatus] = useState<Status>("idle");
const cache = useRef(new Map<string, Suggestion[]>());
useEffect(() => {
const q = query.trim();
if (q.length < minChars) {
setItems([]);
setStatus("idle");
return;
}
if (cache.current.has(q)) {
const cached = cache.current.get(q)!;
setItems(cached);
setStatus(cached.length ? "success" : "empty");
return;
}
const controller = new AbortController();
setStatus("loading");
search(q, controller.signal)
.then((results) => {
cache.current.set(q, results);
setItems(results);
setStatus(results.length ? "success" : "empty");
})
.catch((err: unknown) => {
if (err instanceof DOMException && err.name === "AbortError") return;
setStatus("error");
});
return () => controller.abort();
}, [query, search, minChars]);
return { items, status };
}
Keyboard + open state (core loop)
// Inside Autocomplete
const [value, setValue] = useState("");
const [open, setOpen] = useState(false);
const [active, setActive] = useState(-1);
const debounced = useDebouncedValue(value, debounceMs);
const { items, status } = useAutocompleteSearch(debounced, search, minChars);
function onKeyDown(e: React.KeyboardEvent) {
if (!open && (e.key === "ArrowDown" || e.key === "ArrowUp") && items.length) {
setOpen(true);
}
if (e.key === "ArrowDown") {
e.preventDefault();
setActive((i) => (i + 1) % items.length);
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActive((i) => (i <= 0 ? items.length - 1 : i - 1));
} else if (e.key === "Enter" && active >= 0) {
e.preventDefault();
select(items[active]);
} else if (e.key === "Escape") {
setOpen(false);
setActive(-1);
}
}
function select(item: Suggestion) {
setValue(item.label);
setOpen(false);
setActive(-1);
onSelect(item);
}
Markup skeleton (a11y)
<div className="autocomplete">
<label htmlFor="ac-input">Search</label>
<input
id="ac-input"
role="combobox"
aria-expanded={open}
aria-controls="ac-listbox"
aria-activedescendant={active >= 0 ? `ac-opt-${active}` : undefined}
aria-autocomplete="list"
value={value}
onChange={(e) => {
setValue(e.target.value);
setOpen(true);
setActive(-1);
}}
onKeyDown={onKeyDown}
/>
{open && (
<ul id="ac-listbox" role="listbox">
{status === "loading" && <li role="presentation">Loading…</li>}
{status === "empty" && <li role="presentation">No results</li>}
{items.map((item, i) => (
<li
key={item.id}
id={`ac-opt-${i}`}
role="option"
aria-selected={i === active}
onMouseDown={(e) => e.preventDefault()} // keep focus on input
onClick={() => select(item)}
>
{item.label}
</li>
))}
</ul>
)}
</div>
Use onMouseDown + preventDefault on options so the input doesn’t blur before click registers.
Accessibility notes
- Prefer the combobox pattern (ARIA APG) over reinventing roles.
- Visible focus / active option styles (not color alone).
- Announce loading via polite
aria-liveregion if status is not obvious. - Don’t trap focus in the list; input keeps focus; options are virtual via
aria-activedescendant.
See also Accessible combobox pattern and Focus management.
Performance notes
- Debounce keystrokes; abort in-flight fetch on each new debounced query.
- Cache exact query strings; consider LRU if the interviewer expands scope (LRU Cache).
- Cap rendered options (
maxResults); virtualize only if asked. - Avoid re-creating
searchevery render (wrap inuseCallbackat parent or pass stable mock).
Interview expectations
| Signal | What good looks like |
|---|---|
| Requirements | Min chars, debounce, keyboard, stale fetch called out early |
| Async | AbortController or request-id ignore pattern |
| A11y | combobox/listbox/option without prompting |
| UX | Loading / empty / error |
| Structure | Hook + presentational list, typed model |
| Time | Mouse path MVP in ~25 min, keyboard next |
Extensions they may ask live
- Grouped suggestions (products vs articles)
- Multi-select tokens
- Offline last-successful results
- Analytics: impression / select events
- Server design — see Design Autocomplete Typeahead
Related
Ship a race-safe, keyboardable combobox. Pretty CSS is optional; correct async and ARIA are not.