React Search Autocomplete: A Fast, Accessible Implementation with Hooks

Build a fast, accessible React search autocomplete with hooks, debouncing, ARIA, caching, and virtualization. Includes complete, production-ready code.

ASOasis
9 min read
React Search Autocomplete: A Fast, Accessible Implementation with Hooks

Image used for representation purposes only.

Overview

Search autocomplete is one of those components users touch constantly and judge instantly. It must feel fast, behave predictably on keyboard and touch, and be accessible to assistive technologies. In this article you’ll build a production‑ready React search autocomplete using modern hooks, with debouncing, request cancellation, keyboard navigation, ARIA semantics, caching, and optional virtualization.

What you’ll get:

  • A reusable hook (useAutocomplete) that orchestrates state and behavior
  • A headless, accessible UI that you can style freely
  • Practical guidance on performance, testing, and edge cases

All examples use React 18+ with TypeScript. You can drop types for plain JS.

UX and accessibility principles

Before code, lock in behavior:

  • Delay queries with debounce (150–300 ms) to avoid thrashing.
  • Don’t query until minLength (e.g., 2) characters are typed.
  • Always show clear states: loading, results, no results, error.
  • Keyboard: ArrowDown/Up to navigate, Enter to select, Escape to close.
  • Mouse/touch: clicks select without stealing focus prematurely.
  • Accessibility: follow the ARIA combobox with listbox pattern.
  • Don’t fetch while the user is composing with an IME (e.g., Japanese input).

Data contract

Represent items with a stable id and a label to render and fill:

export type AutoItem = { id: string; label: string } & Record<string, unknown>;
export type Fetcher = (query: string, signal: AbortSignal) => Promise<AutoItem[]>;

Debounce utility

import { useEffect, useState } from "react";

export function useDebouncedValue<T>(value: T, delay = 200) {
  const [debounced, setDebounced] = useState(value);
  useEffect(() => {
    const t = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(t);
  }, [value, delay]);
  return debounced;
}

The useAutocomplete hook

This hook wires together querying, keyboard nav, ARIA, and selection.

import { useCallback, useEffect, useId, useRef, useState } from "react";
import { useDebouncedValue } from "./useDebouncedValue";
import type { AutoItem, Fetcher } from "./types";

export type UseAutocompleteOptions = {
  fetcher: Fetcher;
  minLength?: number;        // default 2
  debounceMs?: number;       // default 200
  closeOnSelect?: boolean;   // default true
};

export function useAutocomplete({
  fetcher,
  minLength = 2,
  debounceMs = 200,
  closeOnSelect = true,
}: UseAutocompleteOptions) {
  const [query, setQuery] = useState("");
  const debouncedQuery = useDebouncedValue(query, debounceMs);
  const [items, setItems] = useState<AutoItem[]>([]);
  const [open, setOpen] = useState(false);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [activeIndex, setActiveIndex] = useState(-1);
  const composingRef = useRef(false);
  const abortRef = useRef<AbortController | null>(null);

  // IDs for ARIA wiring
  const listboxId = useId();
  const inputId = useId();
  const optionId = (i: number) => `${listboxId}-opt-${i}`;

  useEffect(() => {
    if (composingRef.current) return; // pause during IME composition
    if (debouncedQuery.length < minLength) {
      abortRef.current?.abort();
      setItems([]);
      setOpen(false);
      setLoading(false);
      setError(null);
      setActiveIndex(-1);
      return;
    }

    setLoading(true);
    setOpen(true);
    setError(null);
    abortRef.current?.abort();
    const ctrl = new AbortController();
    abortRef.current = ctrl;

    fetcher(debouncedQuery, ctrl.signal)
      .then((res) => {
        setItems(res);
        setActiveIndex(res.length ? 0 : -1);
      })
      .catch((e: any) => {
        if (e?.name !== "AbortError") setError(e?.message ?? "Unknown error");
      })
      .finally(() => setLoading(false));
  }, [debouncedQuery, minLength, fetcher]);

  const select = useCallback(
    (item: AutoItem) => {
      setQuery(item.label);
      if (closeOnSelect) setOpen(false);
      // Expose to parent via returned callback setter if needed
    },
    [closeOnSelect]
  );

  const onKeyDown = useCallback(
    (e: React.KeyboardEvent<HTMLInputElement>) => {
      if (!open && (e.key === "ArrowDown" || e.key === "ArrowUp")) setOpen(true);
      switch (e.key) {
        case "ArrowDown":
          e.preventDefault();
          setActiveIndex((i) => (items.length ? Math.min(i + 1, items.length - 1) : -1));
          break;
        case "ArrowUp":
          e.preventDefault();
          setActiveIndex((i) => (items.length ? Math.max(i - 1, 0) : -1));
          break;
        case "Home":
          if (open && items.length) { e.preventDefault(); setActiveIndex(0); }
          break;
        case "End":
          if (open && items.length) { e.preventDefault(); setActiveIndex(items.length - 1); }
          break;
        case "Enter":
          if (open && activeIndex >= 0 && items[activeIndex]) {
            e.preventDefault();
            select(items[activeIndex]);
          }
          break;
        case "Escape":
          setOpen(false);
          setActiveIndex(-1);
          break;
      }
    },
    [open, items, activeIndex, select]
  );

  const getInputProps = (extra: React.InputHTMLAttributes<HTMLInputElement> = {}) => ({
    id: inputId,
    role: "combobox",
    "aria-expanded": open,
    "aria-controls": listboxId,
    "aria-autocomplete": "list",
    "aria-activedescendant": activeIndex >= 0 ? optionId(activeIndex) : undefined,
    "aria-busy": loading || undefined,
    value: query,
    onChange: (e: React.ChangeEvent<HTMLInputElement>) => setQuery(e.target.value),
    onKeyDown,
    onFocus: () => setOpen(items.length > 0),
    onBlur: (e: React.FocusEvent<HTMLInputElement>) => {
      // Small delay so click on an option can run first
      setTimeout(() => {
        const next = document.activeElement as HTMLElement | null;
        if (!next || next.getAttribute("role") !== "option") setOpen(false);
      }, 0);
    },
    onCompositionStart: () => { composingRef.current = true; },
    onCompositionEnd: () => { composingRef.current = false; },
    ...extra,
  });

  const getListboxProps = (extra: React.HTMLAttributes<HTMLUListElement> = {}) => ({
    id: listboxId,
    role: "listbox",
    tabIndex: -1,
    ...extra,
  });

  const getOptionProps = (index: number, item: AutoItem, extra: React.LiHTMLAttributes<HTMLLIElement> = {}) => ({
    id: optionId(index),
    role: "option",
    "aria-selected": index === activeIndex,
    onMouseEnter: () => setActiveIndex(index),
    onMouseDown: (e: React.MouseEvent) => e.preventDefault(), // keep focus in input
    onClick: () => select(item),
    ...extra,
  });

  return {
    // state
    query, setQuery,
    items, open, loading, error, activeIndex,
    // actions
    select, setOpen, setActiveIndex,
    // props-getters
    getInputProps, getListboxProps, getOptionProps,
    // ids if you need them
    inputId, listboxId,
  } as const;
}

Rendering a headless Autocomplete

This component uses the hook and leaves styling to you.

import React from "react";
import { useAutocomplete } from "./useAutocomplete";
import type { AutoItem, Fetcher } from "./types";

function escapeRegex(s: string) { return s.replace(/[.*+?^${}()|[\\]\\]/g, "\\$&"); }
function Highlight({ text, query }: { text: string; query: string }) {
  if (!query) return <>{text}</>;
  const re = new RegExp(`(${escapeRegex(query)})`, "ig");
  const parts = text.split(re);
  return (
    <>
      {parts.map((p, i) =>
        re.test(p) ? <mark key={i}>{p}</mark> : <span key={i}>{p}</span>
      )}
    </>
  );
}

export function Autocomplete({
  label = "Search",
  placeholder = "Type to search…",
  fetcher,
  onSelect,
}: {
  label?: string;
  placeholder?: string;
  fetcher: Fetcher;
  onSelect?: (item: AutoItem) => void;
}) {
  const ac = useAutocomplete({ fetcher });

  return (
    <div className="ac">
      <label htmlFor={ac.inputId} className="ac__label">{label}</label>
      <input {...ac.getInputProps({ placeholder, className: "ac__input" })} />

      {ac.open && (
        <ul {...ac.getListboxProps({ className: "ac__list" })}>
          {ac.loading && <li role="status" className="ac__status">Loading</li>}
          {!ac.loading && ac.items.length === 0 && (
            <li className="ac__empty">No results</li>
          )}
          {ac.items.map((item, i) => (
            <li
              key={item.id}
              {...ac.getOptionProps(i, item, { className: i === ac.activeIndex ? "ac__option ac__option--active" : "ac__option" })}
              onClick={() => { ac.select(item); onSelect?.(item); }}
            >
              <Highlight text={item.label} query={ac.query} />
            </li>
          ))}
        </ul>
      )}

      {/* Screen-reader live region for result counts */}
      <div aria-live="polite" className="sr-only">
        {ac.loading ? "Loading" : `${ac.items.length} results`}
      </div>
    </div>
  );
}

Minimal styles (optional):

.ac { position: relative; max-width: 480px; }
.ac__list { position: absolute; inset-inline: 0; margin: 4px 0 0; padding: 0; list-style: none; border: 1px solid #d0d7de; border-radius: 8px; background: #fff; max-height: 280px; overflow: auto; box-shadow: 0 6px 24px rgba(0,0,0,0.12); }
.ac__option { padding: 8px 12px; cursor: pointer; }
.ac__option--active { background: #f0f7ff; }
.ac__status, .ac__empty { padding: 8px 12px; color: #6b7280; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); border: 0; }

A robust fetcher with caching and cancellation

Use the browser’s AbortController and a small in-memory cache.

export function createCachedFetcher(
  baseUrl: string,
  ttlMs = 5 * 60 * 1000
): (query: string, signal: AbortSignal) => Promise<AutoItem[]> {
  const cache = new Map<string, { t: number; v: AutoItem[] }>();
  return async (query: string, signal: AbortSignal) => {
    const key = query.trim().toLowerCase();
    const now = Date.now();
    const hit = cache.get(key);
    if (hit && now - hit.t < ttlMs) return hit.v;

    const url = new URL(baseUrl, window.location.origin);
    url.searchParams.set("q", query);

    const res = await fetch(url.toString(), { signal });
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    const data = (await res.json()) as AutoItem[];
    cache.set(key, { t: now, v: data });
    return data;
  };
}

Usage:

const fetcher = createCachedFetcher("/api/search");
<Autocomplete fetcher={fetcher} onSelect={(item) => console.log(item)} />

Large lists? Add virtualization (optional)

If suggestions can exceed ~100 items, render only what’s visible. With react-window:

// npm i react-window
import { FixedSizeList as List } from "react-window";

function VirtualList({ items, rowHeight = 36, renderRow }: { items: AutoItem[]; rowHeight?: number; renderRow: (index: number) => React.ReactNode; }) {
  const height = Math.min(8, items.length) * rowHeight; // show up to 8
  return (
    <List height={height} width="100%" itemCount={items.length} itemSize={rowHeight}>
      {({ index, style }) => <div style={style}>{renderRow(index)}</div>}
    </List>
  );
}

Swap the UL for VirtualList and render rows using getOptionProps. Keep ARIA roles consistent by setting role=“listbox” on the outer container and role=“option” on rows.

Handling edge cases

  • IME composition: The hook pauses querying during composition; keep it.
  • Mobile: Increase target size (44px min), ensure the list doesn’t push input off screen.
  • Blur/focus races: preventDefault on option mousedown; delay closing until after click.
  • Empty query: close the list and abort pending requests.
  • Errors and 429: surface a friendly message and back off retries.
  • Security: Never dangerouslySetInnerHTML with server text; highlight with React nodes as shown.
  • SSR: Guard useId usage to React 18+ (already supported). For Next.js streaming, the code works as-is.

Performance checklist

  • Debounce: 150–300 ms is a good start; lower on fast intranets.
  • Abort previous requests when a new keystroke lands.
  • Cache results for a few minutes; prefetch top queries when the field focuses.
  • Avoid re-creating handlers: memoize fetcher and callbacks when passing through props.
  • Virtualize long lists.
  • Measure: target <8 ms average keystroke-to-paint on mid-tier devices.

Testing strategy

  • Unit-test the hook:
    • Debounce behavior using fake timers
    • Abort on quick successive queries
    • Keyboard navigation and selection
    • IME composition pause/resume
  • Component tests (React Testing Library):
    • ARIA attributes reflect open/closed states
    • Screen-reader announcements (aria-live) update
    • Mouse and touch selection paths
  • Contract tests for the fetcher: map 404/500 to error states, coerce server payloads to AutoItem

Putting it together: a complete example

import React from "react";
import { Autocomplete } from "./Autocomplete";
import { createCachedFetcher } from "./fetcher";

const fetcher = createCachedFetcher("/api/search", 2 * 60 * 1000);

export default function Page() {
  return (
    <section>
      <h2>Search products</h2>
      <Autocomplete
        label="Search products"
        placeholder="Try: keyboard, monitor, mouse…"
        fetcher={fetcher}
        onSelect={(item) => {
          // Navigate, submit form, or populate a filter
          console.log("Selected:", item);
        }}
      />
    </section>
  );
}

Server endpoint sketch (Node/Express):

app.get("/api/search", async (req, res) => {
  const q = String(req.query.q ?? "").trim();
  if (q.length < 2) return res.json([]);
  const results = await searchIndex(q); // your own data source
  // Normalize to AutoItem shape
  res.json(results.map((r: any) => ({ id: String(r.id), label: r.name, ...r })));
});

Troubleshooting

  • The list closes when I click an option: ensure onMouseDown prevents default on options.
  • Screen readers don’t announce options: verify roles and that input has aria-controls and aria-activedescendant.
  • Flickering selection index: reset activeIndex when items change.
  • Requests keep firing while typing fast: confirm debounce and AbortController wiring.

Conclusion

A great autocomplete is equal parts UX discipline and engineering rigor. With a small, focused hook and a headless component, you gain full control over design while meeting performance and accessibility bars. From here you can add niceties like grouped results, recent searches, or server-side ranking—without rewriting the core.

Related Posts