Blog/ENGINEERING

How to Add Autocomplete to a React App

Giorgi Kenchadze

By Giorgi Kenchadze

Oct 30, 2026 · 7 min read

Build a search box with suggestions as you type in React. Debouncing, cancelled requests, keyboard navigation and highlighting, with full code.

This tutorial builds a search box in React that shows suggestions while people type. By the end you'll have a component that fetches suggestions on every keystroke without flickering, highlights what was typed, works with the keyboard, and runs a search when someone picks a suggestion.

If you want the background on how autocomplete works and where suggestions come from, start with Autocomplete API: Add Search-as-You-Type. This post is the hands-on version.

What You'll Need

  • A React app (Vite, Next.js or Create React App all work).
  • Node.js for a small backend.
  • A Vecstore account with a text database. Sign up free and you get 100 credits.

Autocomplete builds its suggestions from the documents already in your database, so if you've inserted products, articles or anything else with text, there's nothing extra to add.

Step 1: A Small Backend

Your API key should never end up in the browser. Anyone could open dev tools, copy it and use your credits. So the React app talks to your own server, and the server talks to Vecstore.

import express from 'express';

const app = express();
app.use(express.json());

const BASE_URL = 'https://api.vecstore.app/api';
const { VECSTORE_API_KEY, VECSTORE_DB_ID } = process.env;

async function vecstore(path, body) {
  const res = await fetch(`${BASE_URL}/databases/${VECSTORE_DB_ID}${path}`, {
    method: 'POST',
    headers: {
      'X-API-Key': VECSTORE_API_KEY,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(body),
  });
  return res.json();
}

app.post('/api/autocomplete', async (req, res) => {
  const query = String(req.body.query ?? '').slice(0, 100);
  res.json(await vecstore('/autocomplete', { query, limit: 6 }));
});

app.post('/api/search', async (req, res) => {
  res.json(await vecstore('/search', { content: req.body.query, top_k: 12 }));
});

app.listen(3001);

Two routes: one for suggestions, one for the actual search. The autocomplete endpoint returns suggestion text only, so when someone picks a suggestion you send it to search to get results.

Step 2: A Hook That Fetches Suggestions

This is where most autocomplete bugs live. If you call the API on every keystroke with no care, two things go wrong:

  1. Too many requests. Typing "headphones" fires ten calls.
  2. Out-of-order answers. The answer for "hea" can arrive after the answer for "head" and overwrite it, so the list jumps back to stale suggestions.

The hook below fixes both. It waits 100ms after the last keystroke, and it aborts the previous request whenever the query changes.

import { useEffect, useState } from 'react';

export function useAutocomplete(query) {
  const [suggestions, setSuggestions] = useState([]);

  useEffect(() => {
    const q = query.trim();
    if (!q) {
      setSuggestions([]);
      return;
    }

    const controller = new AbortController();
    let timer;

    async function load() {
      try {
        const res = await fetch('/api/autocomplete', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ query: q }),
          signal: controller.signal,
        });
        const data = await res.json();

        if (data.status === 'building') {
          timer = setTimeout(load, 1500);
          return;
        }
        setSuggestions(data.suggestions.map((s) => s.text));
      } catch {}
    }

    timer = setTimeout(load, 100);

    return () => {
      clearTimeout(timer);
      controller.abort();
    };
  }, [query]);

  return suggestions;
}

The building check handles the very first call on a database. Vecstore builds the suggestion index on first use and returns status: "building" with no suggestions while it does. The hook quietly retries until it's ready.

Step 3: Highlight What Was Typed

A small detail that makes a big difference: show the part the person typed in a normal weight and the completion in bold. The eye goes straight to the new words.

function Highlight({ text, query }) {
  const typed = query.trimStart();
  if (!text.toLowerCase().startsWith(typed.toLowerCase())) {
    return <strong>{text}</strong>;
  }
  return (
    <>
      {text.slice(0, typed.length)}
      <strong>{text.slice(typed.length)}</strong>
    </>
  );
}

Suggestions keep the casing from your data, so "harry pott" can return "Harry Potter". Comparing in lowercase handles that.

Now put it together. The component handles typing, clicking a suggestion, and the keyboard: arrow keys to move, Enter to pick, Escape to close.

import { useState } from 'react';
import { useAutocomplete } from './useAutocomplete';

export default function SearchBox({ onSearch }) {
  const [query, setQuery] = useState('');
  const [open, setOpen] = useState(false);
  const [active, setActive] = useState(-1);
  const suggestions = useAutocomplete(query);
  const showList = open && suggestions.length > 0;

  function pick(text) {
    if (!text.trim()) return;
    setQuery(text);
    setOpen(false);
    setActive(-1);
    onSearch(text);
  }

  function onKeyDown(e) {
    if (e.key === 'Enter') {
      e.preventDefault();
      pick(active >= 0 ? suggestions[active] : query);
      return;
    }
    if (e.key === 'Escape') {
      setOpen(false);
      return;
    }
    if (!suggestions.length) return;
    if (e.key === 'ArrowDown') {
      e.preventDefault();
      setOpen(true);
      setActive((i) => (i + 1) % suggestions.length);
    } else if (e.key === 'ArrowUp') {
      e.preventDefault();
      setActive((i) => (i <= 0 ? suggestions.length - 1 : i - 1));
    }
  }

  return (
    <div className="search">
      <input
        role="combobox"
        aria-expanded={showList}
        aria-controls="suggestions"
        aria-activedescendant={active >= 0 ? `s-${active}` : undefined}
        value={query}
        placeholder="Search..."
        onChange={(e) => {
          setQuery(e.target.value);
          setOpen(true);
          setActive(-1);
        }}
        onKeyDown={onKeyDown}
        onBlur={() => setOpen(false)}
      />

      {showList && (
        <ul id="suggestions" role="listbox">
          {suggestions.map((text, i) => (
            <li
              key={text}
              id={`s-${i}`}
              role="option"
              aria-selected={i === active}
              className={i === active ? 'active' : ''}
              onMouseDown={(e) => e.preventDefault()}
              onClick={() => pick(text)}
            >
              <Highlight text={text} query={query} />
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

Two lines here are easy to miss:

  • onMouseDown={(e) => e.preventDefault()} stops the input from losing focus when someone clicks a suggestion. Without it, onBlur closes the list before the click registers, and clicks seem to do nothing.
  • The role and aria-* attributes make the list readable by screen readers, which announce the highlighted suggestion as people arrow through it.

Step 5: Some Styling

Keep the list attached to the input and make the active row obvious:

.search { position: relative; max-width: 480px; }
.search input { width: 100%; padding: 12px 14px; font-size: 16px; }
.search ul {
  position: absolute; inset-inline: 0; top: 100%;
  margin: 4px 0 0; padding: 4px; list-style: none;
  background: #fff; border: 1px solid #ddd; border-radius: 6px;
}
.search li { padding: 10px 12px; color: #555; cursor: pointer; }
.search li strong { color: #111; }
.search li.active, .search li:hover { background: #eef4ff; }

The font-size: 16px on the input matters on iPhones. Anything smaller makes Safari zoom in when the field gets focus.

Step 6: Use It

Drop the search box anywhere and pass it a function that runs the real search:

import { useState } from 'react';
import SearchBox from './SearchBox';

export default function App() {
  const [results, setResults] = useState([]);

  async function search(query) {
    const res = await fetch('/api/search', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ query }),
    });
    const data = await res.json();
    setResults(data.results ?? []);
  }

  return (
    <main>
      <SearchBox onSearch={search} />
      <ul>
        {results.map((r) => (
          <li key={r.vector_id}>{r.content}</li>
        ))}
      </ul>
    </main>
  );
}

If you're on Vite, add a proxy so /api reaches your Express server during development:

// vite.config.js
export default {
  server: { proxy: { '/api': 'http://localhost:3001' } },
};

Picking a suggestion runs a semantic search, so the results match by meaning, not only by the exact words in the suggestion.

Things to Watch For

Don't call the API from the browser directly. It's tempting for a quick demo, but it exposes your key. Keep the proxy, even if it's a single serverless function.

Don't debounce too much. 100ms is enough to skip most in-between keystrokes. At 300ms or more, suggestions start to feel like they're lagging behind.

New documents aren't instant. The suggestion index refreshes on its own as your data changes, so a product added a moment ago shows up after the next refresh, not right away.

Autocomplete is for text databases. Image databases have no words to suggest from.

Wrapping Up

That's a complete search-as-you-type box: a two-route backend, a hook that handles timing and stale requests, highlighting, keyboard support and accessible markup. The whole thing is around 150 lines, and the suggestion index, typo tolerance and ranking all happen on Vecstore's side.

Try the live demo on the Autocomplete page, check every parameter in the Autocomplete docs, or create a free account and point the component at your own data.

← Back to all posts

Better search for your product without the engineering overhead.

85M+ searches powered by Vecstore this year

Sign up for Vecstore
Start for Free

100 Free credits. No credit card required.