DEV Community

Rahul Singh
Rahul Singh

Posted on • Originally published at aicodereview.cc

Build an Autocomplete Search Bar with React

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.

Factor Build from Scratch Library (Downshift, React Select, etc.)
Learning value High -- you understand every piece Low -- abstracted away
Bundle size Only what you need (0 KB added) 5-25 KB gzipped
Customization Total control over markup and behavior Constrained by library API
Accessibility You must implement it yourself Usually handled for you
Time to production 4-8 hours 30-60 minutes
Maintenance You own all the edge cases Library maintainers handle updates
Best for Learning, highly custom UIs, minimal bundles Shipping 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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

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

  const handleChange = (e: React.ChangeEvent
Enter fullscreen mode Exit fullscreen mode

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).


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

// Use deferredSuggestions for rendering:
{deferredSuggestions.map((country, index) => (

))}

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

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
Enter fullscreen mode Exit fullscreen mode

Unit tests

// Autocomplete.test.tsx

// 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();
    expect(
      screen.getByRole("combobox")
    ).toBeInTheDocument();
  });

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

    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();

    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();

    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();

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

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

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

    expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
  });
});
Enter fullscreen mode Exit fullscreen mode

Testing keyboard navigation

describe("Keyboard navigation", () => {
  it("navigates suggestions with arrow keys", async () => {
    const user = userEvent.setup();
    render();

    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();

    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();

    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();
  });
});
Enter fullscreen mode Exit fullscreen mode

Testing debounce behavior

describe("Debounce", () => {
  it("does not fetch on every keystroke", async () => {
    const user = userEvent.setup();
    render();

    // 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();

    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();
  });
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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. Caching -- useRef + Map with LRU-style eviction.
  9. Loading/empty states -- Spinner, "no results," and minimum character threshold.
  10. Performance -- React.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.


Originally published at aicodereview.cc

Top comments (0)