guide

Build an Autocomplete Search Bar with React

Build a production-ready autocomplete search component in React with TypeScript. Covers debouncing, keyboard navigation, accessibility, and caching.

Published:

Last Updated:

Introduction

Autocomplete search is one of the most impactful UI patterns on the web. Google, Amazon, GitHub, Spotify — every major product uses it. When users start typing and immediately see relevant suggestions, they find what they need faster, make fewer typos, and discover content they did not know existed. Research from the Baymard Institute shows that autocomplete search reduces search abandonment by up to 24%.

Yet building a production-quality autocomplete component is surprisingly difficult. A naive implementation — slap an onChange handler on an input, fire an API call on every keystroke, render results in a <div> — will work for a demo, but it will buckle under real-world usage. You need debouncing to avoid hammering your API. You need keyboard navigation so users can arrow through results. You need ARIA attributes so screen readers can announce suggestions. You need caching so repeated queries do not re-fetch. You need graceful handling of loading states, empty states, errors, and race conditions.

In this guide, we will build a fully-featured autocomplete search component from scratch in React with TypeScript. By the end, you will have a component that handles all of the above — and you will understand every line of code in it.

What we are building: A search bar that queries the REST Countries API as the user types, displays country suggestions with matched text highlighted, supports full keyboard navigation (arrow keys, Enter, Escape), announces results to screen readers, caches previous queries, and performs well even with hundreds of suggestions.

Build from scratch vs. use a library

Before we start writing code, here is an honest comparison to help you decide whether to build your own or reach for a library.

FactorBuild from ScratchLibrary (Downshift, React Select, etc.)
Learning valueHigh — you understand every pieceLow — abstracted away
Bundle sizeOnly what you need (0 KB added)5-25 KB gzipped
CustomizationTotal control over markup and behaviorConstrained by library API
AccessibilityYou must implement it yourselfUsually handled for you
Time to production4-8 hours30-60 minutes
MaintenanceYou own all the edge casesLibrary maintainers handle updates
Best forLearning, highly custom UIs, minimal bundlesShipping fast, standard patterns

Our recommendation: Build it from scratch at least once. Then use a library like Downshift for production if you do not need heavy customization. This guide gives you the knowledge to do both.

Project Setup

We will use Vite with React and TypeScript. If you already have a project, skip ahead to Step 1.

npm create vite@latest autocomplete-demo -- --template react-ts
cd autocomplete-demo
npm install

File structure

Here is how we will organize the component:

src/
  components/
    Autocomplete/
      Autocomplete.tsx       # Main component
      Autocomplete.css       # Styles
      SuggestionItem.tsx     # Individual suggestion row
      useDebounce.ts         # Debounce hook
      useClickOutside.ts     # Click outside hook
      types.ts               # TypeScript interfaces
  App.tsx                    # Usage example

Types and interfaces

Let us define our data types first. We will use the REST Countries API, which returns country data including name, flag, population, and region.

// types.ts

export interface Country {
  name: {
    common: string;
    official: string;
  };
  cca2: string;
  flags: {
    svg: string;
    png: string;
  };
  population: number;
  region: string;
  capital?: string[];
}

export interface AutocompleteProps {
  /** Placeholder text for the input */
  placeholder?: string;
  /** Minimum characters before suggestions appear */
  minChars?: number;
  /** Debounce delay in milliseconds */
  debounceMs?: number;
  /** Maximum number of suggestions to display */
  maxSuggestions?: number;
  /** Callback when a suggestion is selected */
  onSelect?: (country: Country) => void;
}

export interface SuggestionItemProps {
  country: Country;
  query: string;
  isActive: boolean;
  onSelect: (country: Country) => void;
  onMouseEnter: () => void;
  id: string;
}

Notice that AutocompleteProps is configurable. The caller can control debounce timing, minimum character threshold, and maximum suggestions. This makes the component reusable across different contexts.

Step 1: Basic Search Input

We start with the simplest possible version: a controlled input that tracks what the user types.

// Autocomplete.tsx -- Step 1: Basic input

import { useState } from "react";
import type { AutocompleteProps } from "./types";
import "./Autocomplete.css";

export function Autocomplete({
  placeholder = "Search countries...",
}: AutocompleteProps) {
  const [query, setQuery] = useState("");

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

  return (
    <div className="autocomplete-wrapper">
      <input
        type="text"
        className="autocomplete-input"
        value={query}
        onChange={handleChange}
        placeholder={placeholder}
      />
    </div>
  );
}

Styling

Here is a clean, minimal stylesheet. We use CSS custom properties so the colors are easy to override.

/* Autocomplete.css */

.autocomplete-wrapper {
  --ac-bg: #ffffff;
  --ac-border: #d1d5db;
  --ac-border-focus: #3b82f6;
  --ac-text: #111827;
  --ac-text-muted: #6b7280;
  --ac-highlight: #3b82f6;
  --ac-hover-bg: #f3f4f6;
  --ac-active-bg: #e0e7ff;
  --ac-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1),
    0 2px 4px -2px rgb(0 0 0 / 0.1);
  --ac-radius: 8px;

  position: relative;
  width: 100%;
  max-width: 480px;
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}

.autocomplete-input {
  width: 100%;
  padding: 12px 16px;
  font-size: 16px;
  line-height: 1.5;
  color: var(--ac-text);
  background: var(--ac-bg);
  border: 2px solid var(--ac-border);
  border-radius: var(--ac-radius);
  outline: none;
  transition: border-color 0.15s ease;
  box-sizing: border-box;
}

.autocomplete-input:focus {
  border-color: var(--ac-border-focus);
}

.autocomplete-dropdown {
  position: absolute;
  top: calc(100% + 4px);
  left: 0;
  right: 0;
  max-height: 320px;
  overflow-y: auto;
  background: var(--ac-bg);
  border: 1px solid var(--ac-border);
  border-radius: var(--ac-radius);
  box-shadow: var(--ac-shadow);
  z-index: 50;
  list-style: none;
  margin: 0;
  padding: 4px 0;
}

.suggestion-item {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 10px 16px;
  cursor: pointer;
  transition: background-color 0.1s ease;
}

.suggestion-item:hover,
.suggestion-item[data-active="true"] {
  background: var(--ac-hover-bg);
}

.suggestion-item[data-active="true"] {
  background: var(--ac-active-bg);
}

.suggestion-flag {
  width: 24px;
  height: 16px;
  object-fit: cover;
  border-radius: 2px;
  flex-shrink: 0;
}

.suggestion-name {
  font-size: 14px;
  color: var(--ac-text);
}

.suggestion-name mark {
  background: none;
  color: var(--ac-highlight);
  font-weight: 600;
}

.suggestion-meta {
  font-size: 12px;
  color: var(--ac-text-muted);
  margin-left: auto;
  flex-shrink: 0;
}

.autocomplete-status {
  padding: 12px 16px;
  font-size: 14px;
  color: var(--ac-text-muted);
  text-align: center;
}

.autocomplete-spinner {
  display: inline-block;
  width: 16px;
  height: 16px;
  border: 2px solid var(--ac-border);
  border-top-color: var(--ac-highlight);
  border-radius: 50%;
  animation: spin 0.6s linear infinite;
  margin-right: 8px;
  vertical-align: middle;
}

@keyframes spin {
  to {
    transform: rotate(360deg);
  }
}

/* Screen reader only -- visually hidden but announced */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

At this point, you have a styled input that tracks its own value. Nothing exciting yet, but the foundation is solid.

Step 2: Fetching and Displaying Suggestions

Now we wire up the REST Countries API. When the user types, we fetch matching countries and render them in a dropdown.

// Autocomplete.tsx -- Step 2: Fetching suggestions

import { useState, useEffect } from "react";
import type { AutocompleteProps, Country } from "./types";
import "./Autocomplete.css";

async function fetchCountries(query: string): Promise<Country[]> {
  if (!query.trim()) return [];

  const response = await fetch(
    `https://restcountries.com/v3.1/name/${encodeURIComponent(query)}?fields=name,cca2,flags,population,region,capital`
  );

  if (response.status === 404) return [];
  if (!response.ok) throw new Error(`API error: ${response.status}`);

  return response.json();
}

export function Autocomplete({
  placeholder = "Search countries...",
  minChars = 2,
  maxSuggestions = 8,
  onSelect,
}: AutocompleteProps) {
  const [query, setQuery] = useState("");
  const [suggestions, setSuggestions] = useState<Country[]>([]);
  const [isOpen, setIsOpen] = useState(false);

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

  // Fetch suggestions when query changes
  useEffect(() => {
    if (query.length < minChars) {
      setSuggestions([]);
      setIsOpen(false);
      return;
    }

    let cancelled = false;

    fetchCountries(query).then((results) => {
      if (!cancelled) {
        setSuggestions(results.slice(0, maxSuggestions));
        setIsOpen(results.length > 0);
      }
    });

    return () => {
      cancelled = true;
    };
  }, [query, minChars, maxSuggestions]);

  const handleSelect = (country: Country) => {
    setQuery(country.name.common);
    setIsOpen(false);
    setSuggestions([]);
    onSelect?.(country);
  };

  return (
    <div className="autocomplete-wrapper">
      <input
        type="text"
        className="autocomplete-input"
        value={query}
        onChange={handleChange}
        placeholder={placeholder}
      />
      {isOpen && suggestions.length > 0 && (
        <ul className="autocomplete-dropdown">
          {suggestions.map((country) => (
            <li
              key={country.cca2}
              className="suggestion-item"
              onClick={() => handleSelect(country)}
            >
              <img
                src={country.flags.svg}
                alt={`${country.name.common} flag`}
                className="suggestion-flag"
              />
              <span className="suggestion-name">
                {country.name.common}
              </span>
              <span className="suggestion-meta">
                {country.region}
              </span>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

The cancelled flag pattern is critical. Without it, you get a race condition. If the user types “fra” and then “fran” quickly, the response for “fra” might arrive after “fran” and overwrite the correct results. Setting cancelled = true in the cleanup function ensures stale responses are discarded.

Highlighting matched text

A good autocomplete highlights the portion of each suggestion that matches what the user typed. Here is a utility function that wraps matched substrings in <mark> tags:

// SuggestionItem.tsx

import { memo } from "react";
import type { SuggestionItemProps } from "./types";

function highlightMatch(text: string, query: string): React.ReactNode {
  if (!query.trim()) return text;

  const regex = new RegExp(`(${escapeRegExp(query)})`, "gi");
  const parts = text.split(regex);

  return parts.map((part, i) =>
    regex.test(part) ? <mark key={i}>{part}</mark> : part
  );
}

function escapeRegExp(string: string): string {
  return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

function formatPopulation(pop: number): string {
  if (pop >= 1_000_000) return `${(pop / 1_000_000).toFixed(1)}M`;
  if (pop >= 1_000) return `${(pop / 1_000).toFixed(0)}K`;
  return pop.toString();
}

export const SuggestionItem = memo(function SuggestionItem({
  country,
  query,
  isActive,
  onSelect,
  onMouseEnter,
  id,
}: SuggestionItemProps) {
  return (
    <li
      id={id}
      role="option"
      aria-selected={isActive}
      className="suggestion-item"
      data-active={isActive}
      onClick={() => onSelect(country)}
      onMouseEnter={onMouseEnter}
    >
      <img
        src={country.flags.svg}
        alt=""
        className="suggestion-flag"
        aria-hidden="true"
      />
      <span className="suggestion-name">
        {highlightMatch(country.name.common, query)}
      </span>
      <span className="suggestion-meta">
        {country.region} &middot; {formatPopulation(country.population)}
      </span>
    </li>
  );
});

Notice three things about this component:

  1. escapeRegExp prevents the user’s input from being interpreted as regex. Without this, typing ( would crash the component.
  2. memo prevents unnecessary re-renders. If the country data and active state have not changed, the component skips rendering entirely. We will discuss this more in the performance section.
  3. The flag image has aria-hidden="true" because it is decorative. Screen readers should read the country name, not “United States flag image.”

Step 3: Debouncing

Right now, every keystroke triggers an API call. Type “united states” and that is 13 HTTP requests. Most of those responses will be thrown away before the user even sees them.

Without debouncing: The user types “fran” over 400ms. Four API calls fire: “f”, “fr”, “fra”, “fran”. Only the last result matters.

With debouncing: The user types “fran” over 400ms. We wait 300ms after the last keystroke. One API call fires: “fran”. Three wasted requests eliminated.

Custom useDebounce hook

// useDebounce.ts

import { useState, useEffect } from "react";

/**
 * Debounces a value by the specified delay.
 * Returns the debounced value, which only updates
 * after `delay` ms of inactivity.
 */
export function useDebounce<T>(value: T, delay: number): T {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const timer = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    return () => {
      clearTimeout(timer);
    };
  }, [value, delay]);

  return debouncedValue;
}

This hook is elegantly simple. Every time value changes, it sets a timeout. If value changes again before the timeout fires, the cleanup function clears the old timeout and a new one starts. Only when the user stops changing the value for delay milliseconds does the debounced value update.

Update the main component to use the debounced query for API calls while keeping the input responsive:

// Inside Autocomplete.tsx

const [query, setQuery] = useState("");
const debouncedQuery = useDebounce(query, 300);

// Use debouncedQuery for API calls, not raw query
useEffect(() => {
  if (debouncedQuery.length < minChars) {
    setSuggestions([]);
    setIsOpen(false);
    return;
  }

  let cancelled = false;

  fetchCountries(debouncedQuery).then((results) => {
    if (!cancelled) {
      setSuggestions(results.slice(0, maxSuggestions));
      setIsOpen(results.length > 0);
    }
  });

  return () => {
    cancelled = true;
  };
}, [debouncedQuery, minChars, maxSuggestions]);

The input still updates on every keystroke (so the user sees their typing immediately), but the API call only fires after 300ms of inactivity.

Comparison: useDebounce vs lodash.debounce vs useDeferredValue

ApproachBundle costSSR safeCancelableBest for
Custom useDebounce hook0 KBYesYes (via cleanup)API calls, network requests
lodash.debounce~1.4 KB (tree-shaken)YesYes (cancel())Event handlers, callbacks
useDeferredValue (React 18+)0 KB (built-in)YesAutomaticExpensive renders, filtering local data

When to use which:

  • Use our custom useDebounce hook when you need to debounce a value that triggers side effects (API calls). It is the clearest, most React-idiomatic approach.
  • Use lodash.debounce when you need to debounce a callback function (for example, a scroll handler or resize listener). Wrap it in useMemo to avoid re-creating the debounced function on every render.
  • Use useDeferredValue when you have expensive rendering (like filtering 10,000 items in a list). It tells React “this update can be interrupted by more urgent work.” It does not reduce API calls — it reduces rendering jank.

For autocomplete with a remote API, our custom useDebounce hook is the right choice.

Step 4: Keyboard Navigation

Users expect to navigate autocomplete suggestions with arrow keys, select with Enter, and close with Escape. This is not just a nice-to-have — it is an accessibility requirement.

// Keyboard navigation logic (inside Autocomplete.tsx)

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

// Add to component state:
const [activeIndex, setActiveIndex] = useState(-1);
const listRef = useRef<HTMLUListElement>(null);

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

const handleKeyDown = useCallback(
  (e: React.KeyboardEvent<HTMLInputElement>) => {
    if (!isOpen || suggestions.length === 0) {
      // If user presses down arrow with no open dropdown,
      // trigger a search if we have enough characters
      if (e.key === "ArrowDown" && query.length >= minChars) {
        setIsOpen(true);
      }
      return;
    }

    switch (e.key) {
      case "ArrowDown": {
        e.preventDefault(); // Prevent cursor from moving in input
        setActiveIndex((prev) =>
          prev < suggestions.length - 1 ? prev + 1 : 0
        );
        break;
      }

      case "ArrowUp": {
        e.preventDefault();
        setActiveIndex((prev) =>
          prev > 0 ? prev - 1 : suggestions.length - 1
        );
        break;
      }

      case "Enter": {
        e.preventDefault();
        if (activeIndex >= 0 && activeIndex < suggestions.length) {
          handleSelect(suggestions[activeIndex]);
        }
        break;
      }

      case "Escape": {
        e.preventDefault();
        setIsOpen(false);
        setActiveIndex(-1);
        break;
      }

      case "Tab": {
        // Close dropdown when tabbing away
        setIsOpen(false);
        setActiveIndex(-1);
        break;
      }
    }
  },
  [isOpen, suggestions, activeIndex, query, minChars]
);

Scrolling the active item into view

When the user arrows past the visible area of the dropdown, we need to scroll the active item into view:

// Scroll active suggestion into view
useEffect(() => {
  if (activeIndex < 0 || !listRef.current) return;

  const activeElement = listRef.current.children[activeIndex] as HTMLElement;
  if (activeElement) {
    activeElement.scrollIntoView({
      block: "nearest",
      behavior: "smooth",
    });
  }
}, [activeIndex]);

Wire up the onKeyDown handler on the input:

<input
  type="text"
  className="autocomplete-input"
  value={query}
  onChange={handleChange}
  onKeyDown={handleKeyDown}
  placeholder={placeholder}
/>

And pass isActive and the ref to the dropdown list:

<ul className="autocomplete-dropdown" ref={listRef}>
  {suggestions.map((country, index) => (
    <SuggestionItem
      key={country.cca2}
      id={`suggestion-${index}`}
      country={country}
      query={debouncedQuery}
      isActive={index === activeIndex}
      onSelect={handleSelect}
      onMouseEnter={() => setActiveIndex(index)}
    />
  ))}
</ul>

Note the onMouseEnter handler. When the user hovers over a suggestion with the mouse, we update activeIndex to match. This ensures that keyboard and mouse navigation stay in sync — a detail that many implementations miss.

Step 5: Accessibility (a11y)

Accessibility is not optional. The WAI-ARIA combobox pattern defines exactly how an autocomplete component should behave for assistive technologies. Here are the ARIA attributes we need:

On the wrapper <div>:

  • No specific role needed, but it groups the input and listbox.

On the <input>:

  • role="combobox" — identifies this as a combobox widget.
  • aria-autocomplete="list" — tells screen readers that a list of suggestions will appear.
  • aria-expanded={isOpen} — announces whether the dropdown is open or closed.
  • aria-controls="suggestions-list" — links the input to the listbox.
  • aria-activedescendant — points to the currently highlighted suggestion’s id. This is how screen readers know which suggestion is “focused” without moving DOM focus away from the input.

On the <ul> dropdown:

  • role="listbox" — identifies this as a list of selectable options.
  • id="suggestions-list" — matches the aria-controls value.

On each <li> suggestion:

  • role="option" — identifies this as a selectable option within the listbox.
  • id={"suggestion-" + index} — unique ID for aria-activedescendant to reference.
  • aria-selected set to isActive — marks the currently highlighted option.

Screen reader announcements with a live region

When the number of results changes, screen readers should announce it. We use an aria-live region:

// Live region for result count announcements
const announcementText = (() => {
  if (!isOpen) return "";
  if (suggestions.length === 0) return "No results found.";
  if (suggestions.length === 1) return "1 suggestion available.";
  return `${suggestions.length} suggestions available. Use arrow keys to navigate.`;
})();
{/* Visually hidden, but announced by screen readers */}
<div
  role="status"
  aria-live="polite"
  aria-atomic="true"
  className="sr-only"
>
  {announcementText}
</div>

Here is the fully accessible input markup:

<input
  type="text"
  className="autocomplete-input"
  value={query}
  onChange={handleChange}
  onKeyDown={handleKeyDown}
  placeholder={placeholder}
  role="combobox"
  aria-autocomplete="list"
  aria-expanded={isOpen}
  aria-controls="suggestions-list"
  aria-activedescendant={
    activeIndex >= 0 ? `suggestion-${activeIndex}` : undefined
  }
  autoComplete="off"
  spellCheck={false}
/>

Notice autoComplete="off" — this prevents the browser’s built-in autocomplete from competing with ours. And spellCheck={false} removes the red squiggly underlines that would distract from suggestions.

Step 6: Click Outside to Close

When the user clicks outside the autocomplete component, the dropdown should close. This is a common pattern that deserves its own hook.

// useClickOutside.ts

import { useEffect, type RefObject } from "react";

/**
 * Calls `handler` when a click occurs outside
 * the element referenced by `ref`.
 */
export function useClickOutside(
  ref: RefObject<HTMLElement | null>,
  handler: () => void,
  enabled: boolean = true
) {
  useEffect(() => {
    if (!enabled) return;

    function handleClick(event: MouseEvent) {
      if (
        ref.current &&
        !ref.current.contains(event.target as Node)
      ) {
        handler();
      }
    }

    // Use mousedown, not click, so the dropdown closes
    // before the click lands on whatever the user clicked.
    document.addEventListener("mousedown", handleClick);

    return () => {
      document.removeEventListener("mousedown", handleClick);
    };
  }, [ref, handler, enabled]);
}

Usage in the component:

const wrapperRef = useRef<HTMLDivElement>(null);

useClickOutside(
  wrapperRef,
  () => {
    setIsOpen(false);
    setActiveIndex(-1);
  },
  isOpen // Only listen when the dropdown is open
);

// Wrap the component in the ref'd div
return (
  <div className="autocomplete-wrapper" ref={wrapperRef}>
    {/* ... */}
  </div>
);

Why mousedown instead of click? The click event fires after mouseup. If the user presses down inside the dropdown and releases outside (or vice versa), click can produce unexpected behavior. mousedown fires immediately and gives a cleaner UX.

Why the enabled parameter? We only need the event listener when the dropdown is open. When it is closed, there is no reason to listen for outside clicks. This avoids adding unnecessary event listeners to the document.

Step 7: Caching Results

Every time the user deletes a character and retypes it, we should not re-fetch data we already have. A simple in-memory cache solves this.

// Cache implementation using useRef + Map

const cacheRef = useRef<Map<string, Country[]>>(new Map());

// Modified fetch logic with caching
useEffect(() => {
  if (debouncedQuery.length < minChars) {
    setSuggestions([]);
    setIsOpen(false);
    return;
  }

  const cacheKey = debouncedQuery.toLowerCase();

  // Check cache first
  if (cacheRef.current.has(cacheKey)) {
    const cached = cacheRef.current.get(cacheKey)!;
    setSuggestions(cached.slice(0, maxSuggestions));
    setIsOpen(cached.length > 0);
    return;
  }

  let cancelled = false;

  setIsLoading(true);

  fetchCountries(debouncedQuery)
    .then((results) => {
      if (!cancelled) {
        // Store in cache
        cacheRef.current.set(cacheKey, results);

        // Evict oldest entries if cache is too large
        if (cacheRef.current.size > 50) {
          const firstKey = cacheRef.current.keys().next().value;
          if (firstKey !== undefined) {
            cacheRef.current.delete(firstKey);
          }
        }

        setSuggestions(results.slice(0, maxSuggestions));
        setIsOpen(results.length > 0);
      }
    })
    .catch((err) => {
      if (!cancelled) {
        console.error("Autocomplete fetch error:", err);
        setSuggestions([]);
      }
    })
    .finally(() => {
      if (!cancelled) setIsLoading(false);
    });

  return () => {
    cancelled = true;
  };
}, [debouncedQuery, minChars, maxSuggestions]);

Why useRef and not useState? The cache should not trigger re-renders when it updates. useRef gives us a mutable container that persists across renders without causing them. Putting the cache in useState would cause the component to re-render every time we add a cache entry, which defeats the purpose.

Cache eviction: We use a simple LRU-like strategy: when the cache exceeds 50 entries, we delete the oldest one. For a country search, this is more than sufficient. In a production app with a more active cache, you might use a proper LRU implementation or set TTLs on entries.

Cache invalidation strategy

For most autocomplete use cases, the data does not change while the user is on the page. But if your data is volatile (real-time stock prices, live inventory), you have a few options:

  1. TTL-based expiration: Store a timestamp with each cache entry and ignore entries older than N seconds.
  2. Clear on focus: Wipe the cache when the user focuses the input, ensuring fresh data for each search session.
  3. Stale-while-revalidate: Show cached results immediately, then fetch fresh data in the background and update if the results differ.

Step 8: Loading and Empty States

A polished autocomplete handles three states beyond “showing results”: loading, no results, and below minimum characters.

// Add to component state:
const [isLoading, setIsLoading] = useState(false);

// In the JSX, after the input:
{isOpen && (
  <ul
    className="autocomplete-dropdown"
    role="listbox"
    id="suggestions-list"
    ref={listRef}
  >
    {isLoading ? (
      <li className="autocomplete-status" role="status">
        <span className="autocomplete-spinner" aria-hidden="true" />
        Searching...
      </li>
    ) : suggestions.length === 0 ? (
      <li className="autocomplete-status">
        No countries found for &quot;{debouncedQuery}&quot;
      </li>
    ) : (
      suggestions.map((country, index) => (
        <SuggestionItem
          key={country.cca2}
          id={`suggestion-${index}`}
          country={country}
          query={debouncedQuery}
          isActive={index === activeIndex}
          onSelect={handleSelect}
          onMouseEnter={() => setActiveIndex(index)}
        />
      ))
    )}
  </ul>
)}

The minimum character threshold (minChars) prevents the dropdown from appearing when the user has only typed one character. For the REST Countries API, searching for “a” returns 60+ results, which is not useful. Requiring 2+ characters produces much more relevant suggestions.

Complete Component

Here is the entire component in a single file, fully commented:

// Autocomplete.tsx -- Complete implementation

import {
  useState,
  useEffect,
  useRef,
  useCallback,
  type KeyboardEvent,
  type ChangeEvent,
} from "react";
import { useDebounce } from "./useDebounce";
import { useClickOutside } from "./useClickOutside";
import { SuggestionItem } from "./SuggestionItem";
import type { AutocompleteProps, Country } from "./types";
import "./Autocomplete.css";

// --- API layer ---

async function fetchCountries(query: string): Promise<Country[]> {
  if (!query.trim()) return [];

  const url = `https://restcountries.com/v3.1/name/${encodeURIComponent(query)}?fields=name,cca2,flags,population,region,capital`;
  const response = await fetch(url);

  if (response.status === 404) return []; // No matches
  if (!response.ok) throw new Error(`API error: ${response.status}`);

  return response.json();
}

// --- Component ---

export function Autocomplete({
  placeholder = "Search countries...",
  minChars = 2,
  debounceMs = 300,
  maxSuggestions = 8,
  onSelect,
}: AutocompleteProps) {
  // --- State ---
  const [query, setQuery] = useState("");
  const [suggestions, setSuggestions] = useState<Country[]>([]);
  const [isOpen, setIsOpen] = useState(false);
  const [isLoading, setIsLoading] = useState(false);
  const [activeIndex, setActiveIndex] = useState(-1);

  // --- Refs ---
  const wrapperRef = useRef<HTMLDivElement>(null);
  const inputRef = useRef<HTMLInputElement>(null);
  const listRef = useRef<HTMLUListElement>(null);
  const cacheRef = useRef<Map<string, Country[]>>(new Map());

  // --- Derived values ---
  const debouncedQuery = useDebounce(query, debounceMs);

  // --- Click outside to close ---
  useClickOutside(
    wrapperRef,
    useCallback(() => {
      setIsOpen(false);
      setActiveIndex(-1);
    }, []),
    isOpen
  );

  // --- Fetch suggestions (debounced) ---
  useEffect(() => {
    if (debouncedQuery.length < minChars) {
      setSuggestions([]);
      setIsOpen(false);
      setIsLoading(false);
      return;
    }

    const cacheKey = debouncedQuery.toLowerCase();

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

    let cancelled = false;
    setIsLoading(true);

    fetchCountries(debouncedQuery)
      .then((results) => {
        if (cancelled) return;

        // Cache the results
        cacheRef.current.set(cacheKey, results);

        // Evict oldest entry if cache exceeds limit
        if (cacheRef.current.size > 50) {
          const firstKey = cacheRef.current.keys().next().value;
          if (firstKey !== undefined) {
            cacheRef.current.delete(firstKey);
          }
        }

        setSuggestions(results.slice(0, maxSuggestions));
        setIsOpen(results.length > 0);
      })
      .catch((error) => {
        if (cancelled) return;
        console.error("Autocomplete fetch failed:", error);
        setSuggestions([]);
        setIsOpen(false);
      })
      .finally(() => {
        if (!cancelled) setIsLoading(false);
      });

    return () => {
      cancelled = true;
    };
  }, [debouncedQuery, minChars, maxSuggestions]);

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

  // --- Scroll active item into view ---
  useEffect(() => {
    if (activeIndex < 0 || !listRef.current) return;

    const activeEl = listRef.current.children[activeIndex] as HTMLElement;
    activeEl?.scrollIntoView({ block: "nearest", behavior: "smooth" });
  }, [activeIndex]);

  // --- Handlers ---

  const handleChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
    setQuery(e.target.value);
  }, []);

  const handleSelect = useCallback(
    (country: Country) => {
      setQuery(country.name.common);
      setIsOpen(false);
      setSuggestions([]);
      setActiveIndex(-1);
      onSelect?.(country);
      // Return focus to input after selection
      inputRef.current?.focus();
    },
    [onSelect]
  );

  const handleKeyDown = useCallback(
    (e: KeyboardEvent<HTMLInputElement>) => {
      if (!isOpen || suggestions.length === 0) {
        if (e.key === "ArrowDown" && query.length >= minChars) {
          e.preventDefault();
          setIsOpen(true);
        }
        return;
      }

      switch (e.key) {
        case "ArrowDown":
          e.preventDefault();
          setActiveIndex((prev) =>
            prev < suggestions.length - 1 ? prev + 1 : 0
          );
          break;

        case "ArrowUp":
          e.preventDefault();
          setActiveIndex((prev) =>
            prev > 0 ? prev - 1 : suggestions.length - 1
          );
          break;

        case "Enter":
          e.preventDefault();
          if (activeIndex >= 0 && activeIndex < suggestions.length) {
            handleSelect(suggestions[activeIndex]);
          }
          break;

        case "Escape":
          e.preventDefault();
          setIsOpen(false);
          setActiveIndex(-1);
          break;

        case "Tab":
          setIsOpen(false);
          setActiveIndex(-1);
          break;
      }
    },
    [isOpen, suggestions, activeIndex, query, minChars, handleSelect]
  );

  // --- Screen reader announcement ---
  const announcementText = (() => {
    if (!isOpen) return "";
    if (isLoading) return "Searching...";
    if (suggestions.length === 0) return "No results found.";
    if (suggestions.length === 1) return "1 suggestion available.";
    return `${suggestions.length} suggestions available. Use arrow keys to navigate.`;
  })();

  // --- Render ---
  return (
    <div className="autocomplete-wrapper" ref={wrapperRef}>
      <input
        ref={inputRef}
        type="text"
        className="autocomplete-input"
        value={query}
        onChange={handleChange}
        onKeyDown={handleKeyDown}
        placeholder={placeholder}
        role="combobox"
        aria-autocomplete="list"
        aria-expanded={isOpen}
        aria-controls="suggestions-list"
        aria-activedescendant={
          activeIndex >= 0 ? `suggestion-${activeIndex}` : undefined
        }
        autoComplete="off"
        spellCheck={false}
      />

      {isOpen && (
        <ul
          className="autocomplete-dropdown"
          role="listbox"
          id="suggestions-list"
          ref={listRef}
        >
          {isLoading ? (
            <li className="autocomplete-status" role="status">
              <span className="autocomplete-spinner" aria-hidden="true" />
              Searching...
            </li>
          ) : suggestions.length === 0 ? (
            <li className="autocomplete-status">
              No countries found for &quot;{debouncedQuery}&quot;
            </li>
          ) : (
            suggestions.map((country, index) => (
              <SuggestionItem
                key={country.cca2}
                id={`suggestion-${index}`}
                country={country}
                query={debouncedQuery}
                isActive={index === activeIndex}
                onSelect={handleSelect}
                onMouseEnter={() => setActiveIndex(index)}
              />
            ))
          )}
        </ul>
      )}

      {/* Live region for screen reader announcements */}
      <div
        role="status"
        aria-live="polite"
        aria-atomic="true"
        className="sr-only"
      >
        {announcementText}
      </div>
    </div>
  );
}

Usage example

// App.tsx

import { Autocomplete } from "./components/Autocomplete/Autocomplete";
import type { Country } from "./components/Autocomplete/types";

function App() {
  const handleSelect = (country: Country) => {
    console.log("Selected:", country.name.common);
    console.log("Capital:", country.capital?.[0] ?? "N/A");
    console.log("Population:", country.population.toLocaleString());
  };

  return (
    <main style={{ padding: "2rem", maxWidth: "600px", margin: "0 auto" }}>
      <h1>Country Search</h1>
      <p>Start typing to search for a country.</p>

      <Autocomplete
        placeholder="Search countries..."
        minChars={2}
        debounceMs={300}
        maxSuggestions={8}
        onSelect={handleSelect}
      />
    </main>
  );
}

export default App;

Performance Optimization

Our component already works well, but here are four optimizations that matter at scale.

1. React.memo for suggestion items

We already wrapped SuggestionItem in React.memo. This is important because when the user presses an arrow key, only two items need to re-render: the previously active item and the newly active item. Without memo, all 8+ items would re-render on every keystroke.

You can verify this works by adding a console log inside SuggestionItem:

export const SuggestionItem = memo(function SuggestionItem(props: SuggestionItemProps) {
  console.log("Rendering:", props.country.name.common);
  // ...
});

With memo, you should see only 2 logs per arrow key press, not 8.

2. useCallback for handlers

We wrapped handleChange, handleSelect, and handleKeyDown in useCallback. This ensures the functions maintain referential identity across renders, which in turn allows memo on child components to work correctly.

Without useCallback on handleSelect, every render creates a new function reference, and SuggestionItem’s memo wrapper becomes useless because onSelect is always “new.”

3. Virtualized lists with react-window

If your autocomplete could return hundreds of suggestions, rendering all of them into the DOM is wasteful. Libraries like react-window render only the items visible in the viewport.

// Example with react-window (conceptual)
import { FixedSizeList } from "react-window";

<FixedSizeList
  height={320}
  itemCount={suggestions.length}
  itemSize={48}
  width="100%"
>
  {({ index, style }) => (
    <div style={style}>
      <SuggestionItem
        country={suggestions[index]}
        query={debouncedQuery}
        isActive={index === activeIndex}
        onSelect={handleSelect}
        onMouseEnter={() => setActiveIndex(index)}
        id={`suggestion-${index}`}
      />
    </div>
  )}
</FixedSizeList>

When to use this: If you routinely show more than 50 suggestions, virtualization helps. For 8-10 suggestions, the overhead of a virtualization library is not worth it.

4. useDeferredValue for non-blocking updates

React 18 introduced useDeferredValue, which tells React that a value update can be deferred in favor of more urgent work (like keeping the input responsive).

import { useDeferredValue } from "react";

// Inside the component:
const deferredSuggestions = useDeferredValue(suggestions);
const isStale = deferredSuggestions !== suggestions;

// Use deferredSuggestions for rendering:
{deferredSuggestions.map((country, index) => (
  <SuggestionItem
    key={country.cca2}
    // ...props
  />
))}

// Optionally dim the list while stale results are showing:
<ul
  className="autocomplete-dropdown"
  style={{ opacity: isStale ? 0.7 : 1 }}
>

This is most useful when filtering a large local dataset. If you are fetching from a remote API with debouncing, useDeferredValue adds little benefit because the network latency already creates a natural delay.

Testing the Component

Thorough testing ensures the component works correctly and continues to work as you modify it. Here are tests using React Testing Library and Vitest.

Setup

npm install -D @testing-library/react @testing-library/user-event @testing-library/jest-dom vitest jsdom

Unit tests

// Autocomplete.test.tsx

import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { vi, describe, it, expect, beforeEach } from "vitest";
import { Autocomplete } from "./Autocomplete";

// Mock the fetch API
const mockCountries = [
  {
    name: { common: "France", official: "French Republic" },
    cca2: "FR",
    flags: { svg: "/flags/fr.svg", png: "/flags/fr.png" },
    population: 67390000,
    region: "Europe",
    capital: ["Paris"],
  },
  {
    name: { common: "Finland", official: "Republic of Finland" },
    cca2: "FI",
    flags: { svg: "/flags/fi.svg", png: "/flags/fi.png" },
    population: 5541000,
    region: "Europe",
    capital: ["Helsinki"],
  },
];

beforeEach(() => {
  vi.restoreAllMocks();
  global.fetch = vi.fn().mockResolvedValue({
    ok: true,
    status: 200,
    json: () => Promise.resolve(mockCountries),
  });
});

describe("Autocomplete", () => {
  it("renders the search input", () => {
    render(<Autocomplete />);
    expect(
      screen.getByRole("combobox")
    ).toBeInTheDocument();
  });

  it("does not show suggestions below minChars", async () => {
    const user = userEvent.setup();
    render(<Autocomplete minChars={2} />);

    await user.type(screen.getByRole("combobox"), "f");

    await waitFor(() => {
      expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
    });
  });

  it("shows suggestions after typing enough characters", async () => {
    const user = userEvent.setup();
    render(<Autocomplete minChars={2} debounceMs={0} />);

    await user.type(screen.getByRole("combobox"), "fi");

    await waitFor(() => {
      expect(screen.getByRole("listbox")).toBeInTheDocument();
      expect(screen.getAllByRole("option")).toHaveLength(2);
    });
  });

  it("calls onSelect when a suggestion is clicked", async () => {
    const onSelect = vi.fn();
    const user = userEvent.setup();
    render(<Autocomplete onSelect={onSelect} minChars={2} debounceMs={0} />);

    await user.type(screen.getByRole("combobox"), "fi");

    await waitFor(() => {
      expect(screen.getByRole("listbox")).toBeInTheDocument();
    });

    await user.click(screen.getByText("France"));

    expect(onSelect).toHaveBeenCalledWith(
      expect.objectContaining({
        name: { common: "France", official: "French Republic" },
      })
    );
  });

  it("closes dropdown on Escape", async () => {
    const user = userEvent.setup();
    render(<Autocomplete minChars={2} debounceMs={0} />);

    await user.type(screen.getByRole("combobox"), "fi");

    await waitFor(() => {
      expect(screen.getByRole("listbox")).toBeInTheDocument();
    });

    await user.keyboard("{Escape}");

    expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
  });
});

Testing keyboard navigation

describe("Keyboard navigation", () => {
  it("navigates suggestions with arrow keys", async () => {
    const user = userEvent.setup();
    render(<Autocomplete minChars={2} debounceMs={0} />);

    await user.type(screen.getByRole("combobox"), "fi");

    await waitFor(() => {
      expect(screen.getByRole("listbox")).toBeInTheDocument();
    });

    // Press down arrow -- first item should be active
    await user.keyboard("{ArrowDown}");

    const options = screen.getAllByRole("option");
    expect(options[0]).toHaveAttribute("aria-selected", "true");
    expect(options[1]).toHaveAttribute("aria-selected", "false");

    // Press down arrow again -- second item should be active
    await user.keyboard("{ArrowDown}");

    expect(options[0]).toHaveAttribute("aria-selected", "false");
    expect(options[1]).toHaveAttribute("aria-selected", "true");
  });

  it("wraps around when arrowing past the last item", async () => {
    const user = userEvent.setup();
    render(<Autocomplete minChars={2} debounceMs={0} />);

    await user.type(screen.getByRole("combobox"), "fi");

    await waitFor(() => {
      expect(screen.getAllByRole("option")).toHaveLength(2);
    });

    // Arrow down twice to reach the end, then once more to wrap
    await user.keyboard("{ArrowDown}");
    await user.keyboard("{ArrowDown}");
    await user.keyboard("{ArrowDown}");

    const options = screen.getAllByRole("option");
    expect(options[0]).toHaveAttribute("aria-selected", "true");
  });

  it("selects a suggestion with Enter", async () => {
    const onSelect = vi.fn();
    const user = userEvent.setup();
    render(<Autocomplete onSelect={onSelect} minChars={2} debounceMs={0} />);

    await user.type(screen.getByRole("combobox"), "fi");

    await waitFor(() => {
      expect(screen.getByRole("listbox")).toBeInTheDocument();
    });

    await user.keyboard("{ArrowDown}");
    await user.keyboard("{Enter}");

    expect(onSelect).toHaveBeenCalledTimes(1);
    expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
  });
});

Testing debounce behavior

describe("Debounce", () => {
  it("does not fetch on every keystroke", async () => {
    const user = userEvent.setup();
    render(<Autocomplete minChars={2} debounceMs={300} />);

    // Type quickly -- should not trigger fetch for each keystroke
    await user.type(screen.getByRole("combobox"), "finland");

    // At this point, fetch should not have been called yet
    // (or only for the debounced final value)
    expect(global.fetch).not.toHaveBeenCalled();

    // Wait for debounce to settle
    await waitFor(
      () => {
        expect(global.fetch).toHaveBeenCalledTimes(1);
      },
      { timeout: 500 }
    );
  });

  it("cancels pending debounce when input is cleared", async () => {
    const user = userEvent.setup();
    render(<Autocomplete minChars={2} debounceMs={300} />);

    await user.type(screen.getByRole("combobox"), "fi");
    await user.clear(screen.getByRole("combobox"));

    // Wait past the debounce delay
    await new Promise((r) => setTimeout(r, 400));

    // Fetch should not have been called because the input was cleared
    // before the debounce fired (the cleared value is below minChars)
    expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
  });
});

Production Considerations

Before shipping this component, consider these real-world concerns.

Rate limiting

The REST Countries API is free and does not require authentication, but most production APIs enforce rate limits. Protect against hitting those limits:

// Simple rate limiter
class RateLimiter {
  private timestamps: number[] = [];

  constructor(
    private maxRequests: number,
    private windowMs: number
  ) {}

  canMakeRequest(): boolean {
    const now = Date.now();
    this.timestamps = this.timestamps.filter(
      (t) => now - t < this.windowMs
    );

    if (this.timestamps.length >= this.maxRequests) {
      return false;
    }

    this.timestamps.push(now);
    return true;
  }
}

// Allow 10 requests per second
const limiter = new RateLimiter(10, 1000);

async function fetchCountriesWithRateLimit(
  query: string
): Promise<Country[]> {
  if (!limiter.canMakeRequest()) {
    console.warn("Rate limit reached, skipping request");
    return [];
  }

  return fetchCountries(query);
}

Combined with our 300ms debounce and caching, the rate limiter acts as a safety net for edge cases.

Error handling and retry

Network requests fail. Handle errors gracefully with an exponential backoff retry:

async function fetchWithRetry(
  url: string,
  retries: number = 2,
  delay: number = 500
): Promise<Response> {
  for (let attempt = 0; attempt <= retries; attempt++) {
    try {
      const response = await fetch(url);

      if (response.ok || response.status === 404) {
        return response;
      }

      // Only retry on server errors (5xx)
      if (response.status < 500) {
        return response;
      }
    } catch (error) {
      if (attempt === retries) throw error;
    }

    // Wait before retrying, with exponential backoff
    await new Promise((resolve) =>
      setTimeout(resolve, delay * Math.pow(2, attempt))
    );
  }

  throw new Error("Max retries exceeded");
}

Important: Only retry on network errors and 5xx server errors. Never retry on 4xx client errors — if the server says “bad request,” sending the same request again will not fix it.

Mobile touch support

Our component already works on mobile because we use click and mousedown events, which mobile browsers translate from touch events. However, there are a few mobile-specific improvements worth considering:

  1. Input type: Use type="search" instead of type="text" on mobile. This gives iOS users a “Search” button on the keyboard instead of “Return.”

  2. Viewport meta: Ensure your page has <meta name="viewport" content="width=device-width, initial-scale=1"> to prevent the input from zooming in on iOS when the font size is below 16px.

  3. Touch target size: The WCAG 2.2 minimum touch target size is 24x24px, but Apple recommends 44x44px. Our suggestion items at 48px tall exceed both requirements.

  4. Virtual keyboard offset: When the virtual keyboard opens on mobile, it can obscure the dropdown. Consider scrolling the input into view:

const handleFocus = useCallback(() => {
  // On mobile, scroll the input into view after keyboard opens
  setTimeout(() => {
    inputRef.current?.scrollIntoView({
      behavior: "smooth",
      block: "center",
    });
  }, 300); // Wait for virtual keyboard animation
}, []);

Server-side rendering considerations

If you use Next.js, Remix, or any SSR framework, the autocomplete component needs no special handling because it is entirely client-side (no DOM access during render except through refs, which are null on the server).

However, if you want the input to render during SSR for SEO or initial paint performance:

  1. The initial render (server-side) will show just the input with no dropdown. This is correct — there are no suggestions until the user types.

  2. The useEffect hooks will not run on the server. They only run after hydration on the client, which is when the user can interact with the component.

  3. If you are using Next.js App Router, mark the component with "use client" at the top of the file:

"use client";

import { useState, useEffect, useRef, useCallback } from "react";
// ... rest of the component
  1. Avoid window or document access in render. Our component only accesses the DOM inside useEffect and event handlers, which is SSR-safe.

Error boundary

Wrap the autocomplete in an error boundary so a fetch failure does not crash the entire page:

import { Component, type ReactNode } from "react";

interface ErrorBoundaryProps {
  fallback: ReactNode;
  children: ReactNode;
}

interface ErrorBoundaryState {
  hasError: boolean;
}

class AutocompleteErrorBoundary extends Component<
  ErrorBoundaryProps,
  ErrorBoundaryState
> {
  state: ErrorBoundaryState = { hasError: false };

  static getDerivedStateFromError(): ErrorBoundaryState {
    return { hasError: true };
  }

  componentDidCatch(error: Error) {
    console.error("Autocomplete error:", error);
  }

  render() {
    if (this.state.hasError) {
      return this.props.fallback;
    }
    return this.props.children;
  }
}

// Usage:
<AutocompleteErrorBoundary
  fallback={<input type="text" placeholder="Search (temporarily unavailable)" />}
>
  <Autocomplete onSelect={handleSelect} />
</AutocompleteErrorBoundary>

The fallback renders a plain input so users can still see the search field even if the autocomplete logic fails.

Wrapping Up

Here is a summary of everything we built, step by step:

  1. Basic input — Controlled component with useState.
  2. API integration — Fetch suggestions with race condition handling via the cancelled flag pattern.
  3. Text highlighting — Regex-based <mark> wrapping with proper escaping.
  4. Debouncing — Custom useDebounce hook that reduces API calls by 70-80%.
  5. Keyboard navigation — Arrow keys, Enter, Escape, Tab, with wrap-around.
  6. Accessibility — Full WAI-ARIA combobox pattern with live region announcements.
  7. Click outside — Custom useClickOutside hook with proper cleanup.
  8. CachinguseRef + Map with LRU-style eviction.
  9. Loading/empty states — Spinner, “no results,” and minimum character threshold.
  10. PerformanceReact.memo, useCallback, virtualization guidance, useDeferredValue.
  11. Testing — React Testing Library tests for rendering, keyboard, selection, and debounce.
  12. Production hardening — Rate limiting, error retry, mobile support, SSR safety, error boundaries.

The complete component is around 200 lines of TypeScript. It has zero external dependencies (beyond React itself), full keyboard and screen reader support, and handles the edge cases that trip up most implementations.

If you want to skip the build-from-scratch approach for your next project, the two libraries we recommend are:

  • Downshift by Kent C. Dodds — headless, accessible, highly customizable. Use this if you want full control over rendering.
  • Radix UI Combobox — unstyled, accessible primitives. Use this if you are already in the Radix ecosystem.

Both handle the keyboard navigation and ARIA patterns we built manually, so you can focus on styling and business logic. But now that you have built it from scratch, you understand exactly what those libraries are doing under the hood — and you can debug them when something goes wrong.

Further Reading

Frequently Asked Questions

How do I build an autocomplete search in React?

Create a component with useState for the query and results, implement an onChange handler that filters or fetches data as the user types, add debouncing to limit API calls, render a dropdown of suggestions, and handle keyboard navigation (arrow keys + Enter) for accessibility.

Should I debounce autocomplete API calls in React?

Yes. Without debouncing, every keystroke triggers an API request, which wastes bandwidth and can overwhelm your server. A 300ms debounce delay is the standard recommendation — it feels instant to users while reducing API calls by 70-80%.

How do I make a React autocomplete accessible?

Use ARIA roles (combobox, listbox, option), manage aria-activedescendant for keyboard focus, support arrow key navigation, handle Enter to select, Escape to close, and ensure screen readers announce the number of available suggestions.

What libraries exist for React autocomplete?

Popular options include Downshift (headless, accessible), React Select (feature-rich), Radix UI Combobox (unstyled primitives), and Algolia InstantSearch (for Algolia backends). For learning purposes, building from scratch is recommended.

Explore More

Free Newsletter

Stay ahead with AI dev tools

Weekly insights on AI code review, static analysis, and developer productivity. No spam, unsubscribe anytime.

Join developers getting weekly AI tool insights.

Related Articles