ESC

Type to search the knowledge base.

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)

  1. API contract — GET /search?q= → { id, label }[]
  2. Debounce + AbortController
  3. Active index for keyboard
  4. ARIA roles — combobox / listbox / option
  5. 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;
}
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-live region 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 search every render (wrap in useCallback at 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

  1. Grouped suggestions (products vs articles)
  2. Multi-select tokens
  3. Offline last-successful results
  4. Analytics: impression / select events
  5. Server design — see Design Autocomplete Typeahead

Ship a race-safe, keyboardable combobox. Pretty CSS is optional; correct async and ARIA are not.