Skip to content
fearchitect
UI System-Design Patterns

Autocomplete / Typeahead

Debounced input, AbortController cancellation, and ARIA combobox.

By Abas TurabliReviewed

Summary

Autocomplete fetches suggestions as the user types. The implementation surface covers debouncing keystrokes to avoid flooding the server, cancelling in-flight requests with `AbortController` to prevent out-of-order renders, per-query caching with stale-while-revalidate, and the ARIA combobox pattern for accessible keyboard navigation.

Jump to the interview angle

An autocomplete widget binds an <input> to a suggestion list that updates on every keystroke. Three problems dominate the implementation:

Debounce delays the fetch until the user pauses (typically 200–300 ms), collapsing rapid keystrokes into one request per query.

Request cancellation ensures that if the user types faster than the server responds, only the result for the latest query is applied. AbortController + signal wires this into fetch.

Out-of-order responses — without cancellation, a slow reply for "ab" can resolve after a fast reply for "abc", replacing newer results with older ones. Aborting the previous request prevents this entirely.

Request lifecycle per keystroke

  1. 1

    Keystroke fires

    The input's onChange handler resets a debounce timer. If a timer was already pending, it's cleared. No network call happens yet.

  2. 2

    Debounce timer fires (200–300 ms)

    The timer callback checks the query length against a minimum character threshold (typically 2). Shorter queries are skipped to avoid low-signal results.

  3. 3

    Abort previous request

    If a previous AbortController exists, call controller.abort(). This cancels the in-flight fetch and prevents its .then() chain from running.

  4. 4

    Create new controller and fetch

    Instantiate a new AbortController, pass signal to fetch, then set loading state. The signal is stored in a useRef so the next keystroke can reach it.

  5. 5

    Render results or state

    On success, populate the suggestion list from cache or the fresh response. On abort, do nothing. On network error, show the error state. On empty results, show the empty state.

Debounced fetch with AbortController and per-query cache

A single useRef holds the active controller. Each fetch checks the cache first; on miss it aborts the previous request and fires a new one.

useTypeahead — debounce, abort, cachetsx
import { useState, useEffect, useRef } from "react";

type Status = "idle" | "loading" | "success" | "error";

interface TypeaheadState {
  suggestions: string[];
  status: Status;
}

const cache = new Map<string, string[]>();

export function useTypeahead(query: string, minChars = 2, debounceMs = 250) {
  const [state, setState] = useState<TypeaheadState>({
    suggestions: [],
    status: "idle",
  });
  const controllerRef = useRef<AbortController | null>(null);

  useEffect(() => {
    if (query.length < minChars) {
      setState({ suggestions: [], status: "idle" });
      return;
    }

    // Return cached result immediately (stale-while-revalidate omitted for brevity)
    if (cache.has(query)) {
      setState({ suggestions: cache.get(query)!, status: "success" });
      return;
    }

    const timer = setTimeout(async () => {
      // Abort any in-flight request before starting a new one
      controllerRef.current?.abort();
      const controller = new AbortController();
      controllerRef.current = controller;

      setState((s) => ({ ...s, status: "loading" }));

      try {
        const res = await fetch(
          `/api/suggestions?q=${encodeURIComponent(query)}`,
          { signal: controller.signal }
        );
        if (!res.ok) throw new Error(res.statusText);
        const data: string[] = await res.json();
        cache.set(query, data);
        setState({ suggestions: data, status: "success" });
      } catch (err) {
        if ((err as Error).name === "AbortError") return; // expected; ignore
        setState({ suggestions: [], status: "error" });
      }
    }, debounceMs);

    return () => {
      clearTimeout(timer);
      controllerRef.current?.abort();
    };
  }, [query, minChars, debounceMs]);

  return state;
}

The cleanup function both clears the pending timer and aborts the active request. Checking err.name === 'AbortError' prevents treating a deliberate abort as an error state.

ARIA combobox pattern

Set role="combobox" on the <input>, aria-expanded to reflect whether the list is open, and aria-activedescendant to the id of the focused option. The list element gets role="listbox"; each option gets role="option". Keyboard nav: Arrow keys move aria-activedescendant, Enter selects, Escape closes and returns focus to the input.

Interview angle

Interviewers probe whether you know why AbortController matters — not just that it exists. Explain out-of-order response prevention, then cover the ARIA combobox contract and keyboard nav. Mention cache strategy (Map or SWR-style stale-while-revalidate) and min-chars threshold.

Soundbite: "Debounce batches keystrokes; AbortController prevents stale responses from overwriting fresh ones."

Key terms

AbortController
Browser API that produces a `signal` for cancelling fetch requests mid-flight.
debounce
Delay firing a function until input pauses; collapses rapid events into one call.
ARIA combobox
`role="combobox"` on the input plus `role="listbox"` on the list; enables screen-reader announcement of suggestions.
stale-while-revalidate
Return cached data immediately while fetching an update in the background.
aria-activedescendant
Points the input's accessible focus to the currently highlighted option id in the listbox.

Further reading

Search fearchitect

Jump to a topic, mode, or action.