Autocomplete Search in React: Debounce, Cache, Keyboard Nav
Build a production-ready autocomplete search in React with debouncing, in-memory caching, ARIA keyboard navigation, and Tailwind v4 styling — no heavy libs needed.
Why Autocomplete Is Harder Than It Looks
Honestly, autocomplete is one of those components that looks trivial until you actually ship it to real users. A text input with a dropdown — how complicated can it be? Very, as it turns out.
The basic version takes an afternoon. The version that doesn't hammer your API on every keystroke, doesn't break on fast typists, handles stale responses correctly, and works with a keyboard alone? That takes considerably more thought.
This guide walks through building a fully functional autocomplete component from scratch using React hooks. We'll cover debouncing requests, caching results to avoid redundant network calls, and implementing ARIA-compliant keyboard navigation. No external autocomplete libraries. Just hooks, Tailwind v4, and a clear head.
If you're already comfortable with animated tabs in React or animated buttons, you'll recognize similar patterns here — controlled state, smooth transitions, event delegation. Same mental model, different shape.
Setting Up the Component Structure
Start with the skeleton. The component needs: a controlled text input, a dropdown list of suggestions, loading state, an empty state, and a way to track which item is keyboard-highlighted.
Here's the initial structure before we wire up any logic:
import { useState, useRef, useCallback, useEffect } from 'react';
interface Suggestion {
id: string;
label: string;
sublabel?: string;
}
interface AutocompleteProps {
placeholder?: string;
onSelect: (item: Suggestion) => void;
fetchSuggestions: (query: string) => Promise<Suggestion[]>;
minChars?: number;
}
export function Autocomplete({
placeholder = 'Search...',
onSelect,
fetchSuggestions,
minChars = 2,
}: AutocompleteProps) {
const [query, setQuery] = useState('');
const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
const [isOpen, setIsOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLUListElement>(null);
// ... logic goes here
return (
<div className="relative w-full">
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={placeholder}
className="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-sm text-white placeholder:text-white/40 focus:outline-none focus:ring-2 focus:ring-indigo-500"
role="combobox"
aria-expanded={isOpen}
aria-haspopup="listbox"
aria-autocomplete="list"
/>
{isOpen && (
<ul
ref={listRef}
role="listbox"
className="absolute z-50 mt-2 w-full rounded-xl border border-white/10 bg-zinc-900 py-1 shadow-xl"
>
{/* items */}
</ul>
)}
</div>
);
}Notice the ARIA roles from the start — combobox, listbox, aria-expanded. Adding them later is a pain. The glassmorphism-style input (rgba white overlays, white/5 background) fits naturally into dark UIs — if you want to go deeper on that aesthetic, glassmorphism in UI design is worth reading.
Debouncing the Search Requests
Debouncing means: only fire the fetch after the user has stopped typing for N milliseconds. Without it, a user typing "macbook" generates 7 API requests. With a 300ms debounce, it generates 1.
We'll write a useDebounce hook rather than using an external library. It's 10 lines and you own it fully:
import { useState, useEffect } from 'react';
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}Inside Autocomplete, derive the debounced query and trigger the fetch only when it changes:
const debouncedQuery = useDebounce(query, 300);
useEffect(() => {
if (debouncedQuery.length < minChars) {
setSuggestions([]);
setIsOpen(false);
return;
}
let cancelled = false;
async function load() {
setLoading(true);
try {
const results = await fetchSuggestions(debouncedQuery);
if (!cancelled) {
setSuggestions(results);
setIsOpen(results.length > 0);
setActiveIndex(-1);
}
} finally {
if (!cancelled) setLoading(false);
}
}
load();
return () => { cancelled = true; };
}, [debouncedQuery, minChars, fetchSuggestions]);The cancelled flag is important. If the user types fast enough to trigger two in-flight requests, the first response shouldn't overwrite the second. This prevents race conditions that cause flickering or stale results appearing briefly.
In-Memory Caching to Avoid Redundant Requests
Here's the thing: users backspace and retype the same query constantly. Without a cache, each edit triggers a fresh network request. A simple Map keyed by query string fixes this completely.
const cache = useRef(new Map<string, Suggestion[]>());
// Inside the useEffect, replace the load function:
async function load() {
if (cache.current.has(debouncedQuery)) {
const cached = cache.current.get(debouncedQuery)!;
setSuggestions(cached);
setIsOpen(cached.length > 0);
setActiveIndex(-1);
return;
}
setLoading(true);
try {
const results = await fetchSuggestions(debouncedQuery);
if (!cancelled) {
cache.current.set(debouncedQuery, results);
setSuggestions(results);
setIsOpen(results.length > 0);
setActiveIndex(-1);
}
} finally {
if (!cancelled) setLoading(false);
}
}The useRef placement means the cache persists across renders without triggering them. You probably don't need cache invalidation for most search UIs — the component mounts and unmounts with the page. If you're building something longer-lived, cap the Map size at 50 entries and shift out the oldest ones.
Should the cache live in a useRef vs. outside the component? If you have multiple autocomplete instances that should share suggestions (e.g., a global search bar), lift the Map to module scope. For isolated components, useRef is fine.
Keyboard Navigation with ARIA
Keyboard nav is where most implementations fall apart. The spec is straightforward: Arrow Down moves to the next item, Arrow Up moves up, Enter selects the highlighted item, Escape closes the dropdown. Tab should also close it.
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (!isOpen) return;
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setActiveIndex((i) => Math.min(i + 1, suggestions.length - 1));
break;
case 'ArrowUp':
e.preventDefault();
setActiveIndex((i) => Math.max(i - 1, -1));
break;
case 'Enter':
e.preventDefault();
if (activeIndex >= 0 && suggestions[activeIndex]) {
handleSelect(suggestions[activeIndex]);
}
break;
case 'Escape':
setIsOpen(false);
setActiveIndex(-1);
inputRef.current?.blur();
break;
case 'Tab':
setIsOpen(false);
setActiveIndex(-1);
break;
}
},
[isOpen, suggestions, activeIndex]
);Each list item needs role="option" and aria-selected={index === activeIndex}. The input's aria-activedescendant attribute should point to the ID of the currently highlighted option — screen readers read that aloud as the user navigates.
One gotcha: when activeIndex changes, scroll the highlighted item into view. listRef.current?.children[activeIndex]?.scrollIntoView({ block: 'nearest' }) inside a useEffect watching activeIndex handles that cleanly.
Styling the Dropdown with Tailwind v4
With Tailwind v4.0.2, you get first-class CSS variable tokens and the new @layer behavior out of the box. The dropdown itself needs careful z-index management — it must sit above modals, sticky headers, and anything else in your layout.
// Full item list render
<ul
ref={listRef}
role="listbox"
id="autocomplete-listbox"
className="absolute z-[9999] mt-2 w-full overflow-hidden rounded-xl border border-white/10 bg-zinc-900 shadow-2xl"
>
{loading && (
<li className="px-4 py-3 text-sm text-white/40">Loading...</li>
)}
{!loading && suggestions.length === 0 && query.length >= minChars && (
<li className="px-4 py-3 text-sm text-white/40">No results for "{query}"</li>
)}
{suggestions.map((item, index) => (
<li
key={item.id}
id={`option-${item.id}`}
role="option"
aria-selected={index === activeIndex}
onMouseEnter={() => setActiveIndex(index)}
onClick={() => handleSelect(item)}
className={[
'flex cursor-pointer flex-col gap-0.5 px-4 py-2.5 text-sm transition-colors',
index === activeIndex
? 'bg-indigo-600 text-white'
: 'text-white/70 hover:bg-white/5',
].join(' ')}
>
<span className="font-medium text-white">{item.label}</span>
{item.sublabel && (
<span className="text-xs text-white/40">{item.sublabel}</span>
)}
</li>
))}
</ul>The 8px gap between label and sublabel (gap-0.5 at Tailwind's 4px base = 2px, so use gap-2 for 8px exactly) gives the item enough vertical breathing room. Use py-2.5 on each item rather than padding on the list — it makes the hover state fill edge to edge without gaps.
The highlight color bg-indigo-600 works well on dark backgrounds. If your product uses a different accent, swap it. The important part is sufficient contrast — WCAG AA requires at least 4.5:1 for normal text. Indigo-600 on zinc-900 clears it comfortably.
Handling Click-Outside to Close the Dropdown
What closes the dropdown when a user clicks somewhere else? Not blur events — they fire before click events on list items, which means selecting by click doesn't work. You need a mousedown listener on the document instead.
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
const wrapper = inputRef.current?.closest('[data-autocomplete]');
if (wrapper && !wrapper.contains(e.target as Node)) {
setIsOpen(false);
setActiveIndex(-1);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);Add data-autocomplete to the root wrapper <div>. The closest lookup means you can nest multiple autocomplete instances on a page without them interfering with each other — each instance only closes itself.
Why mousedown instead of click? The sequence of events when clicking a list item is: mousedown → blur (on input) → mouseup → click. If you listen to click, the input blur fires first. Then you close the dropdown. Then the click fires on an element that's now gone. Using mousedown to detect outside-clicks sidesteps this entirely.
Putting It All Together in a Real Search Bar
The component pattern above composes with your data layer cleanly. Pass any async function as fetchSuggestions — REST endpoint, GraphQL query, local fuzzy search with Fuse.js, whatever fits your stack.
For local fuzzy search without a network call, Fuse.js v7 is solid. Initialize it once with your dataset, pass fuse.search(query).slice(0, 8).map(r => r.item) as the resolver. Results feel instant because they are — zero latency.
The same component structure also works well inside a bento grid layout where search occupies a full-width cell, or inside a command palette triggered by Cmd+K. The ARIA attributes you added from the start mean screen reader users get a consistent experience regardless of where it appears.
Should you reach for a library instead? If you're already using Radix UI, Cmdk is excellent for command-palette-style search and covers many edge cases. But for a simple product search bar or a site-wide search field, rolling your own saves a dependency and keeps bundle size down. The hooks above are maybe 120 lines total — readable, owned, tweakable.
FAQ
300ms is the standard starting point. It feels responsive to users without generating too many requests. If your API is slow or expensive, push it to 400-500ms. For local fuzzy search with no network call, you can skip debouncing entirely or use 50ms just to batch rapid keypresses.
Use a cancellation flag (let cancelled = false) inside the useEffect. Set it to true in the cleanup function. Before calling setState inside the async function, check if cancelled is true and bail out if so. This handles the race condition where an older slow request resolves after a newer fast one.
The blur event fires before the click event when a user clicks a list item. If you close the dropdown on blur, the item disappears before the click registers and the selection never fires. Use a mousedown listener on the document instead — mousedown fires before blur, so you can check if the click target is inside the component and only close if it's not.
Set role='combobox' on the input, role='listbox' on the dropdown ul, and role='option' on each li. Set aria-expanded, aria-haspopup='listbox', and aria-autocomplete='list' on the input. Point aria-activedescendant to the id of the currently highlighted option. Screen readers will then announce each item as the user navigates with arrow keys.
For a single component instance, useRef is fine — the cache persists across renders without triggering them, and it's automatically cleaned up when the component unmounts. If you have multiple instances that should share data (like a site-wide search bar that appears in both the header and a mobile drawer), lift the Map to module scope so both instances share the same cache.
Split the suggestion label on the query string (case-insensitive) and wrap matching segments in a <span> with a highlight class. A simple regex like label.split(new RegExp((${escapeRegex(query)}), 'gi')) gives you an array of segments. Odd-indexed segments are matches, even-indexed are non-matches. Render them differently — bold or colored text.