ReactDebounceKeyboard NavigationAccessibilityAPI Integration

Build an Autocomplete / Typeahead

Learn how to build a production-quality autocomplete input from scratch. This is one of the most popular frontend interview questions — it tests debouncing, async data fetching, keyboard navigation, and accessible UI patterns all in one component.

25 min read8 sections
01

What is Autocomplete?

Autocomplete (also called typeahead) is a UI pattern where the application predicts and suggests completions as the user types into an input field. It reduces effort, speeds up input, and helps users discover options they might not know exist.

🔍

Search Engines

Google, YouTube, and Amazon all suggest queries as you type — reducing keystrokes and guiding users to popular searches.

📝

Form Inputs

Address fields, tagging systems, and mention pickers (@user) all rely on typeahead to speed up data entry.

⌨️

Command Palettes

VS Code's Cmd+P, Spotlight, and Slack's slash commands all use autocomplete to surface actions quickly.

Why interviewers love this question

Autocomplete is a "compound" question. It tests debouncing, async state management, keyboard event handling, accessible markup (ARIA), and CSS positioning — all in a single component. It's hard to fake your way through it.

02

How It Works

The mental model is straightforward: user types → fetch suggestions → display dropdown → user picks one. But the devil is in the details.

1

User types in the input

Each keystroke updates the input value. But we don't fire an API call on every keystroke — that would be wasteful.

2

Debounce triggers the search

After the user stops typing for ~300ms, the debounced function fires and sends the query to the API (or filters a local dataset).

3

Suggestions appear in a dropdown

The API returns matching results. We render them in an absolutely-positioned dropdown below the input, with the matching text highlighted.

4

User selects a suggestion

The user can click a suggestion or use keyboard arrows + Enter. The input is populated with the selected value and the dropdown closes.

Lifecycle visualizationtext
User types: "app"

Keystroke:  a       p       p
            |       |       |
Time:      0ms    100ms   200ms  ...  500ms
            ↓       ↓       ↓          ↓
Debounce:  set    reset   reset      FIRESfetch("/api?q=app")

                                    Results: ["Apple", "Appricot"]

                                    Render dropdown with highlights:
                                    [App]le, [App]ricot
03

Core Implementation

Let's start with the basic structure — an input that fetches suggestions and renders them in a dropdown. We'll add debouncing, keyboard nav, and highlighting in subsequent sections.

Autocomplete.tsxtypescript
"use client";

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

interface AutocompleteProps {
  fetchSuggestions: (query: string) => Promise<string[]>;
}

export default function Autocomplete({ fetchSuggestions }: AutocompleteProps) {
  const [query, setQuery] = useState("");
  const [suggestions, setSuggestions] = useState<string[]>([]);
  const [isOpen, setIsOpen] = useState(false);
  const [isLoading, setIsLoading] = useState(false);
  const wrapperRef = useRef<HTMLDivElement>(null);

  // Close dropdown on outside click
  useEffect(() => {
    const handleClickOutside = (e: MouseEvent) => {
      if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
        setIsOpen(false);
      }
    };
    document.addEventListener("mousedown", handleClickOutside);
    return () => document.removeEventListener("mousedown", handleClickOutside);
  }, []);

  const handleChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
    const value = e.target.value;
    setQuery(value);

    if (!value.trim()) {
      setSuggestions([]);
      setIsOpen(false);
      return;
    }

    setIsLoading(true);
    const results = await fetchSuggestions(value);
    setSuggestions(results);
    setIsOpen(results.length > 0);
    setIsLoading(false);
  };

  const handleSelect = (value: string) => {
    setQuery(value);
    setIsOpen(false);
    setSuggestions([]);
  };

  return (
    <div ref={wrapperRef} className="relative">
      <input
        type="text"
        value={query}
        onChange={handleChange}
        placeholder="Search..."
      />

      {isLoading && (
        <div className="absolute mt-1 w-full border rounded p-2 text-sm text-gray-400">
          Loading...
        </div>
      )}

      {isOpen && !isLoading && (
        <ul className="absolute mt-1 w-full border rounded shadow max-h-60 overflow-y-auto">
          {suggestions.map((item) => (
            <li
              key={item}
              onClick={() => handleSelect(item)}
              className="px-3 py-2 cursor-pointer hover:bg-gray-100"
            >
              {item}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

Interview tip

Start with this basic version. Get it working first, then layer on debouncing, keyboard nav, and highlighting. Interviewers prefer incremental progress over a half-finished complex solution.

04

Debouncing the Input

Without debouncing, every keystroke fires an API call. Typing "apple" would trigger 5 requests. With debouncing, we wait until the user pauses, then fire a single request.

useDebounce hooktypescript
import { useRef, useCallback } from "react";

function useDebounce<T extends (...args: any[]) => void>(
  fn: T,
  delay: number
) {
  const timerRef = useRef<ReturnType<typeof setTimeout>>();

  return useCallback(
    (...args: Parameters<T>) => {
      clearTimeout(timerRef.current);
      timerRef.current = setTimeout(() => fn(...args), delay);
    },
    [fn, delay]
  );
}

Now wire it into the component. The key insight: update the input value immediately (so the UI feels responsive), but debounce the API call.

Autocomplete with debouncetypescript
const fetchResults = async (value: string) => {
  if (!value.trim()) {
    setSuggestions([]);
    setIsOpen(false);
    setIsLoading(false);
    return;
  }
  setIsLoading(true);
  const results = await fetchSuggestions(value);
  setSuggestions(results);
  setIsOpen(results.length > 0);
  setIsLoading(false);
};

const debouncedFetch = useDebounce(fetchResults, 300);

const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  const value = e.target.value;
  setQuery(value);           // Immediate — keeps input responsive
  setIsLoading(true);        // Show spinner right away
  debouncedFetch(value);     // Actual fetch is debounced
};

Race condition warning

If the user types "ap", pauses, then types "app", two requests fire. The response for "ap" might arrive after "app", showing stale results. Fix this with an AbortController or by checking if the query still matches when the response arrives.

Handling race conditions with AbortControllertypescript
const abortRef = useRef<AbortController>();

const fetchResults = async (value: string) => {
  // Cancel the previous in-flight request
  abortRef.current?.abort();
  abortRef.current = new AbortController();

  try {
    const results = await fetchSuggestions(value, abortRef.current.signal);
    setSuggestions(results);
    setIsOpen(results.length > 0);
  } catch (err) {
    if (err instanceof DOMException && err.name === "AbortError") {
      return; // Intentionally cancelled — ignore
    }
    console.error(err);
  } finally {
    setIsLoading(false);
  }
};
05

Keyboard Navigation

A proper autocomplete must be navigable with the keyboard. This is both a UX requirement and an accessibility requirement.

KeyBehavior
ArrowDownMove highlight to the next suggestion (wrap to top)
ArrowUpMove highlight to the previous suggestion (wrap to bottom)
EnterSelect the currently highlighted suggestion
EscapeClose the dropdown without selecting
Keyboard handlertypescript
const [focusedIndex, setFocusedIndex] = useState(-1);

// Reset focused index when suggestions change
useEffect(() => {
  setFocusedIndex(-1);
}, [suggestions]);

const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
  if (!isOpen) return;

  switch (e.key) {
    case "ArrowDown":
      e.preventDefault();
      setFocusedIndex((prev) =>
        prev < suggestions.length - 1 ? prev + 1 : 0
      );
      break;
    case "ArrowUp":
      e.preventDefault();
      setFocusedIndex((prev) =>
        prev > 0 ? prev - 1 : suggestions.length - 1
      );
      break;
    case "Enter":
      e.preventDefault();
      if (focusedIndex >= 0) {
        handleSelect(suggestions[focusedIndex]);
      }
      break;
    case "Escape":
      setIsOpen(false);
      setFocusedIndex(-1);
      break;
  }
};

// On the input:
<input onKeyDown={handleKeyDown} ... />

// On each list item — highlight the focused one:
<li
  className={`px-3 py-2 cursor-pointer ${
    index === focusedIndex ? "bg-gray-100" : "hover:bg-gray-50"
  }`}
>
  {item}
</li>

Scroll into view

When the user arrows past the visible area, the highlighted item should scroll into view. Add a ref to the list and call scrollIntoView({ block: "nearest" }) on the focused element whenever focusedIndex changes.

06

Highlighting Matched Text

A polished autocomplete highlights the portion of each suggestion that matches the user's query. This gives visual feedback about why each result was returned.

HighlightMatch componenttypescript
function HighlightMatch({ text, query }: { text: string; query: string }) {
  if (!query.trim()) return <span>{text}</span>;

  // Escape special regex characters in the query
  const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
  const regex = new RegExp(`(${escaped})`, "gi");
  const parts = text.split(regex);

  return (
    <span>
      {parts.map((part, i) =>
        regex.test(part) ? (
          <mark key={i} className="bg-yellow-100 text-yellow-900 rounded px-0.5">
            {part}
          </mark>
        ) : (
          <span key={i}>{part}</span>
        )
      )}
    </span>
  );
}

// Usage in the dropdown:
<li>
  <HighlightMatch text={suggestion} query={query} />
</li>

Why escape the query?

If the user types a character like "(" or ".", it would break the regex. Always escape user input before using it in a RegExp constructor. This is a common interview gotcha.

07

Edge Cases & Enhancements

The basic autocomplete covers the happy path. Here are the edge cases interviewers will probe:

Edge CaseSolution
Race conditionsUse AbortController to cancel stale requests, or compare the query at response time with the current input value
Empty queryClear suggestions and close dropdown when input is empty. Don't fire API calls for blank strings
No resultsShow a "No results found" message instead of an empty dropdown
API errorsWrap fetch in try/catch. Show an error state or silently fail — don't crash the component
Long listsCap results (e.g., top 10) and use max-height + overflow-y-auto on the dropdown
CachingCache previous results in a Map. If the user types "app", deletes to "ap", then retypes "app", serve from cache
Minimum query lengthDon't search until the user has typed at least 2-3 characters to avoid overly broad results
Simple result cachingtypescript
const cacheRef = useRef<Map<string, string[]>>(new Map());

const fetchResults = async (value: string) => {
  const key = value.toLowerCase().trim();

  // Return cached results if available
  if (cacheRef.current.has(key)) {
    const cached = cacheRef.current.get(key)!;
    setSuggestions(cached);
    setIsOpen(cached.length > 0);
    setIsLoading(false);
    return;
  }

  const results = await fetchSuggestions(value);
  cacheRef.current.set(key, results); // Cache for future use
  setSuggestions(results);
  setIsOpen(results.length > 0);
  setIsLoading(false);
};

ARIA attributes for accessibility

Use role="combobox" on the input, role="listbox" on the dropdown, and role="option" on each item. Set aria-activedescendant to the ID of the focused option. This makes the component usable with screen readers.

08

Common Interview Follow-up Questions

After building the autocomplete, expect these follow-ups:

Q:How do you handle race conditions with async suggestions?

A: Use an AbortController to cancel the previous request when a new one fires. Alternatively, store a request ID and only apply results if the ID matches the latest request. This prevents stale data from overwriting fresh results.

Q:How would you add caching to avoid redundant API calls?

A: Use a Map (or useRef with a Map) keyed by the normalized query string. Before fetching, check the cache. This is especially useful when users delete characters and retype — the results are already available instantly.

Q:What ARIA attributes does an autocomplete need?

A: The input needs role='combobox', aria-expanded, aria-autocomplete='list', and aria-activedescendant pointing to the focused option's ID. The dropdown needs role='listbox'. Each option needs role='option' and a unique ID. This follows the WAI-ARIA combobox pattern.

Q:How would you handle a very large dataset (100k+ items)?

A: Never filter on the client. Use server-side search with LIMIT/pagination. On the frontend, virtualize the dropdown list (only render visible items). Also consider a minimum query length (2-3 chars) to avoid overly broad searches.

Q:What's the difference between autocomplete and a combobox?

A: Autocomplete suggests completions for free-text input — the user can type anything. A combobox restricts selection to predefined options (like a searchable select). In practice, the terms are often used interchangeably, but the distinction matters for form validation.

Q:How would you debounce vs throttle the input?

A: Debounce is the right choice here — you want to wait until the user stops typing. Throttle would fire at regular intervals while typing, which means unnecessary intermediate API calls. Debounce gives you one call after the burst ends.

Q:How do you handle the dropdown positioning near the bottom of the viewport?

A: Check if there's enough space below the input. If not, flip the dropdown above. Libraries like Floating UI handle this automatically. In an interview, mentioning this shows you think about real-world UX.

Ready to implement it yourself?

We've set up a playground with a mock API and a search input. Build the full autocomplete with debouncing, keyboard navigation, and text highlighting.

Built for developers, by developers. Happy coding! 🚀