Skip to content
Intermediate45 min build target7 min guide

Autocomplete

A search input that suggests matches as the user types, with keyboard navigation and cancellation of stale requests.

Autocomplete interface reference

HOW TO USE THIS CHALLENGE

  1. 1. Read the briefClarify decisions before coding.
  2. 2. Build from memoryUse the 45-minute target.
  3. 3. Study the guideCompare architecture, tests, and trade-offs.

REQUIREMENTS

  • Debounce input so a request isn't fired on every keystroke.
  • Render a dropdown of matching suggestions below the input.
  • Support keyboard navigation: arrow keys move highlight, Enter selects, Escape closes.
  • Highlight the matched substring within each suggestion.
  • Clicking outside the dropdown closes it without changing the input.
  • Show empty and loading states distinctly.

EDGE CASES

  • Responses can arrive out of order — a slow request for an earlier query must not overwrite a newer result.
  • Empty query should clear suggestions rather than show all results.
  • Rapid backspacing shouldn't reopen a stale dropdown from an in-flight request.
  • No matches found should render a clear empty state, not a blank dropdown.

ACCESSIBILITY

  • Use a combobox pattern: input has role="combobox", aria-expanded, aria-controls pointing at the listbox.
  • Suggestions list has role="listbox"; each option role="option" with aria-selected reflecting the highlighted item.
  • Announce the active option via aria-activedescendant instead of moving real focus off the input.

SUGGESTED APPROACH

  • Keep query, suggestions, activeIndex, and a requestId (or AbortController) in state.
  • Debounce the query before firing the request; on each new request, increment/track a request token so a resolved-but-stale response is ignored.
  • Derive the dropdown's open/closed state from whether there are suggestions and whether the input is focused, not a separate boolean that can drift out of sync.
  • Handle keyboard events on the input itself (not the list) so focus never leaves the field.

EVALUATION RUBRIC

  • Correctly cancels/ignores stale responses when queries resolve out of order.
  • Keyboard flow (arrows, Enter, Escape) works without the browser scrolling the page.
  • Combobox ARIA relationships are wired correctly, not just visually styled to look like one.
  • Debounce delay is tuned so typing feels responsive, not laggy.

01Understand the product before coding

Learning goals

  • Debounce input so a request isn't fired on every keystroke.
  • Render a dropdown of matching suggestions below the input.
  • Support keyboard navigation: arrow keys move highlight, Enter selects, Escape closes.
  • Highlight the matched substring within each suggestion.

Decisions to state aloud

  • Responses can arrive out of order — a slow request for an earlier query must not overwrite a newer result.
  • Empty query should clear suggestions rather than show all results.
  • Rapid backspacing shouldn't reopen a stale dropdown from an in-flight request.
  • No matches found should render a clear empty state, not a blank dropdown.

02State model and invariants

Separate immediate input from the debounced query. Store the active option by stable id and make request status a discriminated union. Open state is derived from focus, query validity, and results; it is not another boolean that can contradict them.

TypeScript
type Suggestion = { id: string; label: string };type Results =  | { status: 'idle'; items: [] }  | { status: 'loading'; items: Suggestion[] }  | { status: 'success'; items: Suggestion[] }  | { status: 'error'; items: []; message: string };type State = {  input: string; activeId: string | null; focused: boolean; results: Results;};

03Component architecture

  1. 01Autocomplete owns input, active id, request lifecycle, and selection.
  2. 02SuggestionList renders listbox semantics without duplicating selection state.
  3. 03SuggestionOption highlights text using React nodes rather than raw HTML.
  4. 04useDebouncedValue controls timing; one request effect owns AbortController cleanup.
  5. 05A hidden status region announces loading, count, empty, and error states.

04Reference implementation walkthrough

Step 1

Create a cancellable request pipeline

Normalize and debounce the input, abort the request owned by the previous query, and ignore AbortError. Debouncing reduces frequency; cancellation prevents obsolete work from committing.

TSX
useEffect(() => {  if (query.length < 2) return setResults({ status: 'idle', items: [] });  const controller = new AbortController();  setResults((old) => ({ status: 'loading', items: old.items }));  search(query, controller.signal)    .then((items) => setResults({ status: 'success', items }))    .catch((error) => error.name !== 'AbortError' &&      setResults({ status: 'error', items: [], message: 'Try again' }));  return () => controller.abort();}, [query]);

Step 2

Keep DOM focus in the text field

Arrow keys update activeId, Enter commits, and Escape closes. aria-activedescendant makes the virtual focus perceivable without breaking normal text editing.

TSX
<input role="combobox" aria-autocomplete="list"  aria-expanded={open} aria-controls="suggestions"  aria-activedescendant={activeId ? `option-${activeId}` : undefined} /><ul id="suggestions" role="listbox">  {items.map((item) => <li id={`option-${item.id}`} role="option"    aria-selected={item.id === activeId} key={item.id}>{item.label}</li>)}</ul>

Step 3

Make keyboard movement pure

Use visible stable ids, deliberately choose wrap or clamp behavior, and clear the active id when new results no longer contain it. Native cursor movement keys remain untouched.

Step 4

Highlight and dismiss safely

Split labels at match boundaries and render mark plus text nodes. Close when focus leaves the whole widget, on Escape, or after selection; clean any document-level pointer listener.

SOLComplete solution, explained simply

Build it yourself first. This is one correct implementation, not the only one — read it top to bottom, then compare the shape of your version.

We are building a search box that shows a dropdown of matching suggestions as you type. The one big idea: keep two separate pieces of text. One updates on every keystroke so typing feels instant. The other is a delayed copy that waits until you pause, and only that one asks the server. Everything else — cancelling old requests, keyboard arrows, the highlight — hangs off those two values.

Autocomplete.tsx
import { useEffect, useId, useRef, useState } from "react";
type Suggestion = { id: string; label: string };type Results =  | { status: "idle" }  | { status: "loading"; items: Suggestion[] }  | { status: "success"; items: Suggestion[] }  | { status: "error"; message: string };
// Copies `value` across only after it stops changing for `ms` milliseconds.function useDebounced<T>(value: T, ms: number) {  const [debounced, setDebounced] = useState(value);  useEffect(() => {    const timer = setTimeout(() => setDebounced(value), ms);    return () => clearTimeout(timer);  }, [value, ms]);  return debounced;}
export function Autocomplete({  search,}: {  search: (query: string, signal: AbortSignal) => Promise<Suggestion[]>;}) {  const [input, setInput] = useState("");        // updates every keystroke  const [activeId, setActiveId] = useState<string | null>(null);  const [open, setOpen] = useState(false);  const [results, setResults] = useState<Results>({ status: "idle" });  const query = useDebounced(input.trim(), 250);   // updates when you pause  const listId = useId();  const rootRef = useRef<HTMLDivElement>(null);
  // 1. Fetch when the paused query changes. Cancel the request before it.  useEffect(() => {    if (query.length < 2) {      setResults({ status: "idle" });      return;    }    const controller = new AbortController();    setResults((prev) => ({      status: "loading",      items: "items" in prev ? prev.items : [],    }));    search(query, controller.signal)      .then((items) => setResults({ status: "success", items }))      .catch((error) => {        if (error.name !== "AbortError") {          setResults({ status: "error", message: "Something went wrong. Try again." });        }      });    return () => controller.abort();  }, [query, search]);
  const items = "items" in results ? results.items : [];
  // 2. If the list refreshes and the highlighted item is gone, drop the highlight.  useEffect(() => {    if (activeId && !items.some((item) => item.id === activeId)) setActiveId(null);  }, [items, activeId]);
  // 3. Close the dropdown when focus leaves the whole widget.  useEffect(() => {    const element = rootRef.current;    function onFocusOut(event: FocusEvent) {      if (!element?.contains(event.relatedTarget as Node)) setOpen(false);    }    element?.addEventListener("focusout", onFocusOut);    return () => element?.removeEventListener("focusout", onFocusOut);  }, []);
  function move(delta: number) {    if (items.length === 0) return;    const index = items.findIndex((item) => item.id === activeId);    const next = (index + delta + items.length) % items.length; // wraps around    setActiveId(items[next].id);  }
  function select(suggestion: Suggestion) {    setInput(suggestion.label);    setOpen(false);    setActiveId(null);  }
  function onKeyDown(event: React.KeyboardEvent) {    if (event.key === "ArrowDown") { event.preventDefault(); setOpen(true); move(1); }    else if (event.key === "ArrowUp") { event.preventDefault(); setOpen(true); move(-1); }    else if (event.key === "Enter" && activeId) {      event.preventDefault();      const chosen = items.find((item) => item.id === activeId);      if (chosen) select(chosen);    }    else if (event.key === "Escape") setOpen(false);  }
  const showList =    open && (results.status === "loading" || items.length > 0 || results.status === "error");
  return (    <div ref={rootRef} className="autocomplete">      <input        role="combobox"        aria-expanded={showList}        aria-controls={listId}        aria-autocomplete="list"        aria-activedescendant={activeId ? listId + "-" + activeId : undefined}        value={input}        onChange={(event) => { setInput(event.target.value); setOpen(true); }}        onFocus={() => setOpen(true)}        onKeyDown={onKeyDown}      />
      {showList && (        <ul id={listId} role="listbox">          {items.map((item) => (            <li              key={item.id}              id={listId + "-" + item.id}              role="option"              aria-selected={item.id === activeId}              onMouseDown={(event) => event.preventDefault()} // keep the input focused              onClick={() => select(item)}            >              {highlight(item.label, query)}            </li>          ))}          {results.status === "success" && items.length === 0 && <li>No matches</li>}          {results.status === "error" && <li role="alert">{results.message}</li>}        </ul>      )}
      <p role="status" className="sr-only">        {results.status === "loading" ? "Loading suggestions"          : results.status === "success" ? items.length + " suggestions"          : results.status === "error" ? results.message : ""}      </p>    </div>  );}
// Slice the label around the typed text and wrap the match in a real <mark>.// Never uses innerHTML, so a label from the server cannot inject anything.function highlight(label: string, query: string) {  if (!query) return label;  const at = label.toLowerCase().indexOf(query.toLowerCase());  if (at === -1) return label;  return (    <>      {label.slice(0, at)}      <mark>{label.slice(at, at + query.length)}</mark>      {label.slice(at + query.length)}    </>  );}

How each part works

Two texts: input and query

input changes on every keystroke, so what you see in the box is never behind your typing. query is a delayed copy made by useDebounced: it only catches up 250ms after you stop typing. The network request is tied to query, not input, so a fast typist causes one request at the end instead of one per letter.

results has a status label

Instead of separate isLoading, error, and items values that could disagree with each other, one object holds exactly one of: idle, loading, success, error. It is impossible to be 'loading and also showing an error' because the shape does not allow it.

Each new search cancels the previous one

AbortController is a stop button for a fetch. When query changes, React first runs the previous effect's cleanup — controller.abort() — and only then starts the new request. So a slow old response cannot land late and replace newer results. We ignore AbortError because that error is us cancelling on purpose, not a real failure.

The keyboard highlight is just an id

The text cursor never leaves the input, so you can keep typing and use Home or End normally. activeId remembers which option is highlighted, and aria-activedescendant tells a screen reader 'the user is on this option' without moving the browser's real focus. Arrow keys change activeId; Enter picks whatever it points at.

move wraps around the list

It finds the current option's position, adds +1 or -1, then uses the remainder operator so going past the last option loops back to the first, and going up from the first loops to the last. Adding items.length before the remainder keeps it working for negative numbers.

Effect 2 keeps the highlight honest

When results refresh, the previously highlighted option might not be in the new list. This effect clears activeId in that case, so the highlight never points at something that is not on screen.

Three ways to close

Escape closes it. Clicking an option selects it and closes. Moving focus out of the whole widget closes it — the focusout listener checks whether the new focus target is still inside rootRef. Each option calls preventDefault on mousedown so the click does not yank focus out of the input before onClick runs.

The hidden status line

The role="status" paragraph is visually hidden but read aloud by screen readers when it changes, announcing 'Loading suggestions', '5 suggestions', or the error message, so a non-sighted user knows what happened.

highlight builds elements, not HTML

It cuts the label into before-match, match, and after-match pieces and wraps the middle in a real <mark> element. Because it never touches innerHTML, a label coming from an untrusted server cannot smuggle in a script.

Why this is correct

  • An instant input plus a delayed query is what makes the box feel fast without sending a request per keystroke.
  • Debouncing only reduces how often you ask; cancelling the old request (and letting React run the cleanup first) is what actually stops a stale answer from winning.
  • A single status field makes impossible combinations like 'loading and errored' unrepresentable.
  • The highlight is a remembered id plus aria-activedescendant, so real keyboard focus stays in the input the entire time.
  • Rendering the match with a React <mark> element instead of innerHTML means server text can never become executable code.

05Testing strategy

Critical behavior

  • Correctly cancels/ignores stale responses when queries resolve out of order.
  • Keyboard flow (arrows, Enter, Escape) works without the browser scrolling the page.
  • Combobox ARIA relationships are wired correctly, not just visually styled to look like one.
  • Debounce delay is tuned so typing feels responsive, not laggy.

Failure and boundary cases

  • Responses can arrive out of order — a slow request for an earlier query must not overwrite a newer result.
  • Empty query should clear suggestions rather than show all results.
  • Rapid backspacing shouldn't reopen a stale dropdown from an in-flight request.
  • No matches found should render a clear empty state, not a blank dropdown.

Accessibility

  • Use a combobox pattern: input has role="combobox", aria-expanded, aria-controls pointing at the listbox.
  • Suggestions list has role="listbox"; each option role="option" with aria-selected reflecting the highlighted item.
  • Announce the active option via aria-activedescendant instead of moving real focus off the input.

06Performance and production hardening

  • Bound and expire any query cache.
  • Limit suggestion count instead of mounting hundreds of options.
  • Create locale collators once per locale.
  • Measure service latency separately from debounce delay.

QAInterview questions and model answers

Answer aloud first. Then open the model answer and compare state ownership, failure handling, accessibility, and trade-offs—not exact wording.

Model answer

Typing must update immediately while the query is delayed. Combining them either fetches every keystroke or makes the visible input lag.

Primary references

Ready to build it?

Implement the brief above in your own environment against a timer close to 45 minutes, then self-review against the rubric before moving on.

Back to all briefs →