Building a Fast, Accessible React Autocomplete with Search Suggestions

Build a fast, accessible React autocomplete with debounced fetch, ARIA, keyboard support, caching, and virtualization—complete with TypeScript code.

ASOasis
7 min read
Building a Fast, Accessible React Autocomplete with Search Suggestions

Image used for representation purposes only.

Overview

Autocomplete is one of those details that makes a product feel fast and helpful—when it’s done well. In React, a great autocomplete must be accessible, debounced, cancelable, keyboard‑friendly, and resilient to slow or flaky networks. This guide walks through a headless, production‑ready approach, plus notes on performance, testing, and alternatives.

What “good” looks like

A polished autocomplete should provide:

  • Instant feedback without blocking typing
  • Keyboard navigation (ArrowUp/Down, Enter, Esc, Tab)
  • Proper ARIA semantics (combobox + listbox + option)
  • Debounced, cancelable queries with race‑condition safety
  • Error/empty states that don’t thrash the UI
  • Screen reader announcements and visible focus states
  • Support for IME (e.g., Chinese/Japanese/Korean) composition
  • Virtualization for large result sets

Data flow and state model

  • Controlled input: inputValue drives the component.
  • Derived suggestions: fetched by query; loading/error state tracked.
  • Open/close: isOpen toggles on focus/typing and closes on blur or selection.
  • Highlight: highlightedIndex follows arrows and resets on value change.
  • Selection: onSelect(item) returns a strong value to the parent.

Accessibility essentials (ARIA 1.2 combobox)

  • Input: role=“combobox”; aria-autocomplete=“list”; aria-expanded; aria-controls to link the popup list; aria-activedescendant to point to the active option.
  • List: role=“listbox” with id referenced by aria-controls.
  • Options: role=“option” with aria-selected for the highlighted item.
  • Keyboard: ArrowDown/Up moves focus, Enter selects, Esc closes, Tab confirms.
  • Announcements: keep focused item obvious; keep DOM order linear.

Complete, headless Autocomplete (TypeScript)

This example is framework‑agnostic in styling and can plug into any data source. It includes debouncing, request cancelation, IME handling, and ARIA.

import React, {useEffect, useId, useMemo, useRef, useState} from 'react';

type Item = { id: string; label: string; meta?: string };

type AutocompleteProps = {
  fetchSuggestions: (q: string, signal: AbortSignal) => Promise<Item[]>;
  onSelect: (item: Item) => void;
  placeholder?: string;
  minLength?: number; // e.g., 2
  debounceMs?: number; // e.g., 200
  getOptionLabel?: (item: Item) => string;
};

export function Autocomplete({
  fetchSuggestions,
  onSelect,
  placeholder = 'Search…',
  minLength = 2,
  debounceMs = 200,
  getOptionLabel = (i) => i.label,
}: AutocompleteProps) {
  const inputId = useId();
  const listId = useId();
  const [inputValue, setInputValue] = useState('');
  const [suggestions, setSuggestions] = useState<Item[]>([]);
  const [isOpen, setIsOpen] = useState(false);
  const [highlightedIndex, setHighlightedIndex] = useState<number>(-1);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const compositionRef = useRef(false); // IME composition guard
  const abortRef = useRef<AbortController | null>(null);
  const inputRef = useRef<HTMLInputElement | null>(null);

  // Simple in-memory cache
  const cacheRef = useRef(new Map<string, Item[]>());

  // Debounced query value
  const [debounced, setDebounced] = useState(inputValue);
  useEffect(() => {
    const t = setTimeout(() => setDebounced(inputValue), debounceMs);
    return () => clearTimeout(t);
  }, [inputValue, debounceMs]);

  // Fetch suggestions when debounced value changes
  useEffect(() => {
    if (debounced.length < minLength) {
      setSuggestions([]);
      setError(null);
      setLoading(false);
      return;
    }

    // Cache hit short‑circuit
    if (cacheRef.current.has(debounced)) {
      setSuggestions(cacheRef.current.get(debounced)!);
      setIsOpen(true);
      setError(null);
      setLoading(false);
      return;
    }

    abortRef.current?.abort();
    const controller = new AbortController();
    abortRef.current = controller;

    setLoading(true);
    setError(null);

    fetchSuggestions(debounced, controller.signal)
      .then((items) => {
        cacheRef.current.set(debounced, items);
        setSuggestions(items);
        setIsOpen(true);
        setHighlightedIndex(items.length ? 0 : -1);
      })
      .catch((e) => {
        if (e.name !== 'AbortError') {
          setError('Could not load suggestions');
          setSuggestions([]);
          setIsOpen(true); // show error/empty state
        }
      })
      .finally(() => setLoading(false));
  }, [debounced, minLength, fetchSuggestions]);

  // Close on outside click
  useEffect(() => {
    function onDocClick(e: MouseEvent) {
      if (!inputRef.current) return;
      if (e.target instanceof Node && !inputRef.current.parentElement?.contains(e.target)) {
        setIsOpen(false);
      }
    }
    document.addEventListener('mousedown', onDocClick);
    return () => document.removeEventListener('mousedown', onDocClick);
  }, []);

  const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
    if (compositionRef.current) return; // don’t interfere during IME
    if (!isOpen) return;
    if (e.key === 'ArrowDown') {
      e.preventDefault();
      setHighlightedIndex((i) => Math.min(i + 1, suggestions.length - 1));
    } else if (e.key === 'ArrowUp') {
      e.preventDefault();
      setHighlightedIndex((i) => Math.max(i - 1, 0));
    } else if (e.key === 'Enter') {
      if (highlightedIndex >= 0 && highlightedIndex < suggestions.length) {
        e.preventDefault();
        const item = suggestions[highlightedIndex];
        onSelect(item);
        setIsOpen(false);
      }
    } else if (e.key === 'Escape') {
      setIsOpen(false);
    }
  };

  const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setInputValue(e.target.value);
    setIsOpen(true);
    setHighlightedIndex(0);
  };

  const onOptionMouseEnter = (index: number) => setHighlightedIndex(index);
  const onOptionMouseDown = (item: Item) => {
    // mousedown to select before blur fires
    onSelect(item);
    setIsOpen(false);
  };

  return (
    <div style={{ position: 'relative', maxWidth: 440 }}>
      <input
        id={inputId}
        ref={inputRef}
        role="combobox"
        aria-autocomplete="list"
        aria-expanded={isOpen}
        aria-controls={listId}
        aria-activedescendant={
          highlightedIndex >= 0 && suggestions[highlightedIndex]
            ? `${listId}-opt-${suggestions[highlightedIndex].id}`
            : undefined
        }
        placeholder={placeholder}
        value={inputValue}
        onChange={onChange}
        onFocus={() => setIsOpen(true)}
        onKeyDown={onKeyDown}
        onCompositionStart={() => (compositionRef.current = true)}
        onCompositionEnd={() => (compositionRef.current = false)}
        autoComplete="off"
        style={{ width: '100%', padding: '8px 12px' }}
      />

      {isOpen && (
        <div
          role="listbox"
          id={listId}
          style={{
            position: 'absolute',
            top: '100%',
            left: 0,
            right: 0,
            background: 'white',
            border: '1px solid #e5e7eb',
            borderTop: 'none',
            zIndex: 10,
            maxHeight: 280,
            overflowY: 'auto',
          }}
        >
          {loading && <div style={{ padding: 8 }}>Loading…</div>}
          {!loading && error && <div style={{ padding: 8, color: '#b91c1c' }}>{error}</div>}
          {!loading && !error && suggestions.length === 0 && debounced.length >= minLength && (
            <div style={{ padding: 8, color: '#6b7280' }}>No results</div>
          )}
          {!loading && !error && suggestions.map((item, i) => {
            const active = i === highlightedIndex;
            return (
              <div
                key={item.id}
                id={`${listId}-opt-${item.id}`}
                role="option"
                aria-selected={active}
                onMouseEnter={() => onOptionMouseEnter(i)}
                onMouseDown={() => onOptionMouseDown(item)}
                style={{
                  padding: '8px 12px',
                  background: active ? '#eef2ff' : 'transparent',
                  cursor: 'pointer',
                }}
              >
                <div style={{ fontWeight: 500 }}>{getOptionLabel(item)}</div>
                {item.meta && (
                  <div style={{ fontSize: 12, color: '#6b7280' }}>{item.meta}</div>
                )}
              </div>
            );
          })}
        </div>
      )}
    </div>
  );
}

Example fetcher with cancelation and server‑side filtering:

export async function fetchCities(q: string, signal: AbortSignal) {
  const url = new URL('/api/cities', window.location.origin);
  url.searchParams.set('q', q);
  const res = await fetch(url.toString(), { signal });
  if (!res.ok) throw new Error('Network error');
  return (await res.json()) as { id: string; label: string; meta?: string }[];
}

Server stub (Node/Express‑style):

app.get('/api/cities', (req, res) => {
  const q = String(req.query.q || '').toLowerCase();
  const all = citiesData; // preloaded
  const out = q.length < 2
    ? []
    : all.filter(c => c.label.toLowerCase().includes(q)).slice(0, 20);
  res.json(out);
});

Performance patterns that matter

  • Debounce vs throttle: Debounce (150–300ms) is best for network search; throttle is useful for telemetry.
  • Cancel aggressively: Abort previous fetches when a new query starts; prevents “results jump” from stale responses.
  • Cache the obvious: Cache per‑query results for the session; invalidate when relevant data changes.
  • Prefetch: On focus, prefetch trending or last‑used queries to avoid an empty popup.
  • Virtualize: If you regularly render >100 options, window the list with your favorite virtualization library. Keep aria-activedescendant synchronized with the virtual row.
  • React 18 niceties: useDeferredValue(inputValue) smooths typing while results render; useTransition can keep the input snappy when switching large lists.

UX details that reduce friction

  • Minimum characters: Avoid spamming the network; 2–3 chars is a good default.
  • Match highlighting: Emphasize the matched substring in results (render safely; avoid dangerouslySetInnerHTML unless you sanitize).
  • Keyboard predictability: Always keep one option highlighted when open; pressing Enter should be deterministic.
  • Empty + error states: Be explicit—show “No results” or a retry affordance.
  • Mobile ergonomics: Increase hit targets (44px tap areas), keep the list above the keyboard where possible, and avoid scroll‑jank.
  • IME composition: Do not move highlight or submit during composition; ignore keydown until compositionend.

Security and privacy

  • Sanitize any server‑returned HTML. Prefer plain text; render emphasis yourself.
  • Rate‑limit or require auth for expensive endpoints.
  • Avoid logging full PII queries; hash or truncate where possible.

Testing the component

Use React Testing Library and user‑event to simulate user behavior:

// Pseudocode
it('opens and fetches on typing with debounce', async () => {
  render(<Autocomplete fetchSuggestions={mockFetch} onSelect={fn()} />);
  await user.click(screen.getByRole('combobox'));
  await user.type(screen.getByRole('combobox'), 'san');
  // advance timers if you mock timers to pass debounce
  expect(await screen.findByRole('listbox')).toBeInTheDocument();
  expect(screen.getAllByRole('option').length).toBeGreaterThan(0);
});

it('navigates with arrows and selects with Enter', async () => {
  await user.type(input, 'tok');
  await user.keyboard('[ArrowDown][ArrowDown][Enter]');
  expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ label: /tok/i }));
});

it('cancels stale requests on fast typing', async () => {
  // Assert only latest fetch resolves and populates suggestions
});

Enhancements and variants

  • Multi‑select: Track selected[]; render chips; filter out selected items from suggestions.
  • Async + local hybrid: Show cached/local results instantly, then merge with remote when it lands.
  • Grouped results: Render headings (role=“presentation”) to keep the listbox semantics valid.
  • Free‑solo: Allow arbitrary text if Enter is pressed with no option highlighted.
  • Spell correction: Surface “Did you mean …” items ahead of standard results.

Using a library instead

If you prefer to avoid writing behaviors by hand, mature options include:

  • Headless hooks/components: Downshift, React Aria’s useComboBox.
  • Full UI kits: MUI Autocomplete, Chakra Combobox, Radix primitives. Pros: accessible by default, battle‑tested keyboard handling, extensive props. Cons: bundle size, styling constraints, and less control over fetch/caching.

Debug checklist

  • aria-controls points to your listbox id; aria-activedescendant points to the active option id.
  • Input stays focused while navigating; list never steals focus.
  • Debounce is short enough to feel instant but long enough to avoid thrash (≈200ms).
  • AbortController cancels in‑flight requests on each new keystroke.
  • No warnings in the console for keys/ids; options have stable keys.
  • Tab behavior is intentional: confirm selection or move focus according to your product norms.

Conclusion

A React autocomplete that feels “instant” is the sum of many small choices: accessible semantics, careful keyboard support, debounced and cancelable networking, caching, and clean error/empty states. Start with a headless core like the example above, then layer on styling, virtualization, and analytics. You’ll end up with suggestions that genuinely help users get where they’re going—faster.

Related Posts