RoadmapDay 56 / 80
React (Web)Month 3 · Week 12

Day 56: Build: Custom Autocomplete / Typeahead Component

Build a fully accessible, debounced, keyboard-navigable autocomplete from scratch — a machine-coding interview staple.

Mark this day complete

Study

Concepts

The requirements that make this deceptively hard

A correct autocomplete needs: debounced input (Day 13's dynamic-window instincts apply — do not fire a request per keystroke), race-condition-safe async handling (a slow response for an OLD query must not overwrite a newer query's results — this is the exact "stale response" problem from Day 24/28), full keyboard navigation (ArrowUp/Down to move a highlighted option, Enter to select, Escape to close), and correct ARIA roles (`combobox`, `listbox`, `option`, `aria-activedescendant`) so screen reader users get an equivalent experience.

This single component is a genuine synthesis of Month 1's async/closure fundamentals with real UI engineering — it is one of the most common "build X" interview prompts precisely because it touches so many fundamentals at once.

See It

Visualizations

Visualization

Keystroke to rendered results

User types

local input state updates instantly

Debounce ~300ms

wait for typing to pause before firing a request

Fetch, tagged with a request id

lets late responses be discarded safely

Render results + reset highlighted index

Build It

Code Examples

A complete, accessible, race-safe autocomplete

jsx
function Autocomplete({ fetchSuggestions, onSelect }) {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  const [highlighted, setHighlighted] = useState(-1);
  const [isOpen, setIsOpen] = useState(false);
  const requestIdRef = useRef(0);

  useEffect(() => {
    if (!query) { setResults([]); return; }

    const timeoutId = setTimeout(async () => {
      const thisRequestId = ++requestIdRef.current; // tag this request
      const data = await fetchSuggestions(query);
      if (thisRequestId !== requestIdRef.current) return; // a newer query won the race — discard
      setResults(data);
      setHighlighted(-1);
      setIsOpen(true);
    }, 300);

    return () => clearTimeout(timeoutId); // cancel the pending debounce on every keystroke
  }, [query, fetchSuggestions]);

  function handleKeyDown(e) {
    if (!isOpen) return;
    if (e.key === 'ArrowDown') { e.preventDefault(); setHighlighted((i) => Math.min(i + 1, results.length - 1)); }
    if (e.key === 'ArrowUp') { e.preventDefault(); setHighlighted((i) => Math.max(i - 1, 0)); }
    if (e.key === 'Enter' && highlighted >= 0) { selectItem(results[highlighted]); }
    if (e.key === 'Escape') { setIsOpen(false); }
  }

  function selectItem(item) {
    onSelect(item);
    setQuery(item.label);
    setIsOpen(false);
  }

  return (
    <div role="combobox" aria-expanded={isOpen} aria-haspopup="listbox">
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        onKeyDown={handleKeyDown}
        aria-activedescendant={highlighted >= 0 ? `option-${highlighted}` : undefined}
        aria-autocomplete="list"
      />
      {isOpen && (
        <ul role="listbox">
          {results.map((item, i) => (
            <li
              key={item.id}
              id={`option-${i}`}
              role="option"
              aria-selected={i === highlighted}
              onMouseEnter={() => setHighlighted(i)}
              onClick={() => selectItem(item)}
              style={{ background: i === highlighted ? '#eee' : undefined }}
            >
              {item.label}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

Remember

Key Takeaways

  • Debounce the network call, not the input's visual update — the input must always feel instant.
  • Tag every async request with an incrementing ID and discard responses that are no longer the latest — this is the general fix for stale-response races.
  • Full keyboard support (Arrow keys, Enter, Escape) and correct ARIA roles are not optional polish — they are core requirements of this component.
  • Reset the highlighted index whenever the result set changes, or a stale highlight can point at the wrong/nonexistent item.
  • This component is a checklist interviewers use to probe several fundamentals at once — treat it as a study synthesis, not just a UI task.

Do It

Practice

  1. 1Extend the component to highlight the matched substring within each result label.
  2. 2Add a "no results found" state and a loading spinner state, keeping ARIA correct for both.
  3. 3Convert the debounce+race-guard logic into a reusable useDebouncedAsync(fn, delay) custom hook (ties back to Day 24).