FrontendPro

77Build a typeahead/autocomplete with caching and keyboard support

mediumAbout 45 minutesCodingFreereactaccessibilitycomboboxcachingkeyboard

Problem

Build <Autocomplete fetchSuggestions={fetchSuggestions} onSelect={onSelect} debounceMs={200} />. fetchSuggestions(query) resolves to an array of strings.

Follow the ARIA combobox pattern:

  • <input role="combobox" aria-autocomplete="list" aria-expanded aria-controls="{listboxId}" aria-activedescendant="{activeOptionId}">
  • <ul role="listbox" id="{listboxId}"> with <li role="option" id="…" aria-selected="true|false"> for each suggestion, shown only while open.

Requirements

  • Debounce: fetch only after the user stops typing for debounceMs. Do not fetch for an empty (trimmed) query.
  • Cache results by query: typing a query you already searched shows the cached results without a request.
  • Ignore responses for queries that are no longer current.
  • ArrowDown and ArrowUp move the active option (wrapping); the active option has aria-selected="true" and its id is the input's aria-activedescendant.
  • Enter selects the active option: put it in the input, close the list and call onSelect(value). Clicking an option does the same.
  • Escape closes the list.

Try it yourself

Write your solution, run it, then submit to check it against the tests.

App.jsxReact + DOM

Video walkthrough

Stuck? Watch someone solve it, then try again on your own.

Mastering React Interview Questions : Autocomplete or TypeaheadRahul SriRam on YouTubeOpen on YouTube

Step by step

Build the solution one idea at a time.

  1. 1

    State

    text (the input value), suggestions, open, and activeIndex (-1 when nothing is active). A cache ref holds a Map from query to results, and a latestQuery ref remembers which query the UI is waiting for.

  2. 2

    Debounced, cached fetching

    An effect on [text] starts a timer. When it fires: trim the query; if it is empty, close and stop. If the cache has it, show the cached results. Otherwise remember it as latestQuery, fetch, store the response in the cache, and only show it if the query is still latestQuery.

  3. 3

    Keyboard handling on the input

    ArrowDown opens the list and moves activeIndex forward, wrapping to 0; ArrowUp moves back, wrapping to the end. Enter selects the active option. Escape closes. Call preventDefault for the arrows so the cursor does not jump.

  4. 4

    Point screen readers at the active option

    Focus stays in the input the whole time. aria-activedescendant names the active option's id, which makes screen readers announce it as if it were focused. Build option ids from useId() so several autocompletes on a page do not clash.

  5. 5

    Selecting with the mouse

    Use onMouseDown with preventDefault on options instead of onClick. The input would otherwise lose focus (blur) before the click registers, which some implementations use to close the list.

  6. 6

    Commit a selection

    select(value): set text to the value, close the list, reset activeIndex, and call onSelect. Mark the value as "just selected" so the effect does not immediately search for it again.

Approach

A debounced effect turns typing into cached, race-safe lookups; the input owns keyboard handling and exposes the active option through aria-activedescendant, which is the standard accessible combobox.

Solution

import { useEffect, useId, useRef, useState } from "react";

function Autocomplete({ fetchSuggestions, onSelect = () => {}, debounceMs = 200 }) {
  const id = useId();
  const listboxId = `${id}-listbox`;
  const optionId = (i) => `${id}-option-${i}`;
  const [text, setText] = useState("");
  const [suggestions, setSuggestions] = useState([]);
  const [open, setOpen] = useState(false);
  const [activeIndex, setActiveIndex] = useState(-1);
  const cache = useRef(new Map());
  const latestQuery = useRef("");
  const skipNextSearch = useRef(false);

  useEffect(() => {
    if (skipNextSearch.current) {
      skipNextSearch.current = false;
      return;
    }
    const timer = setTimeout(() => {
      const query = text.trim();
      latestQuery.current = query;
      if (!query) {
        setSuggestions([]);
        setOpen(false);
        return;
      }
      const show = (list) => {
        if (latestQuery.current !== query) return; // a newer query owns the list
        setSuggestions(list);
        setActiveIndex(-1);
        setOpen(true);
      };
      if (cache.current.has(query)) {
        show(cache.current.get(query));
        return;
      }
      fetchSuggestions(query).then((list) => {
        cache.current.set(query, list);
        show(list);
      });
    }, debounceMs);
    return () => clearTimeout(timer);
  }, [text, debounceMs, fetchSuggestions]);

  function select(value) {
    skipNextSearch.current = true;
    setText(value);
    setOpen(false);
    setActiveIndex(-1);
    onSelect(value);
  }

  function onKeyDown(event) {
    const count = suggestions.length;
    if (event.key === "ArrowDown" && count > 0) {
      event.preventDefault();
      setOpen(true);
      setActiveIndex((i) => (i + 1) % count);
    } else if (event.key === "ArrowUp" && count > 0) {
      event.preventDefault();
      setOpen(true);
      setActiveIndex((i) => (i <= 0 ? count - 1 : i - 1));
    } else if (event.key === "Enter" && open && activeIndex >= 0) {
      event.preventDefault();
      select(suggestions[activeIndex]);
    } else if (event.key === "Escape") {
      setOpen(false);
      setActiveIndex(-1);
    }
  }

  const expanded = open && suggestions.length > 0;

  return (
    <div style={{ position: "relative", width: 280 }}>
      <input
        role="combobox"
        aria-autocomplete="list"
        aria-expanded={expanded}
        aria-controls={listboxId}
        aria-activedescendant={expanded && activeIndex >= 0 ? optionId(activeIndex) : undefined}
        value={text}
        onChange={(e) => setText(e.target.value)}
        onKeyDown={onKeyDown}
        placeholder="Search a city"
        style={{ width: "100%" }}
      />
      {expanded && (
        <ul role="listbox" id={listboxId} style={{ listStyle: "none", margin: 0, padding: 4, border: "1px solid #d2d2d7", borderRadius: 8 }}>
          {suggestions.map((item, i) => (
            <li
              key={item}
              id={optionId(i)}
              role="option"
              aria-selected={i === activeIndex}
              onMouseDown={(e) => {
                e.preventDefault();
                select(item);
              }}
              style={{ padding: "4px 8px", borderRadius: 6, background: i === activeIndex ? "#e8f0fe" : "transparent", cursor: "pointer" }}
            >
              {item}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

// Preview
const CITIES = ["Ahmedabad", "Bengaluru", "Bhopal", "Chennai", "Delhi", "Hyderabad", "Jaipur", "Kolkata", "Lucknow", "Mumbai", "Pune", "Surat"];
const fakeFetch = (q) => new Promise((resolve) => setTimeout(() => resolve(CITIES.filter((c) => c.toLowerCase().startsWith(q.toLowerCase()))), 300));
function App() {
  const [picked, setPicked] = useState("");
  return (
    <div>
      <Autocomplete fetchSuggestions={fakeFetch} onSelect={setPicked} />
      <p>Selected: {picked}</p>
    </div>
  );
}

Complexity

Time: one request per distinct query after a pause; O(k) to render k suggestions. Space: O(distinct queries × results) for the cache.

Edge cases

  • Fast typing sends one request for the final text.
  • Going back to a previous query uses the cache instantly.
  • A slow response for an old query never replaces newer suggestions.
  • Arrow keys wrap around; Enter with no active option does nothing.
  • Selecting an option does not trigger a new search for the selected text.

Follow-up questions

Answer each one out loud first, then reveal the model answer.