Search Bar in React: Debounce, Autocomplete and Keyboard Navigation
Build a React search bar with debounce, autocomplete dropdown, and full keyboard navigation — the patterns that actually hold up in production.
Why Your Basic onChange Handler Isn't Enough
Most React search bars start the same way — a controlled input, an onChange, and a filter call inside the handler. It works. Until your dataset hits a few hundred items or you're firing API requests, and suddenly you've got a laggy UI and a server bill that's climbing.
The core problem is that onChange fires on every single keystroke. Type "react hooks" and you've dispatched nine separate events before you've even finished the word. In practice, almost none of those intermediate states matter to the user.
What you actually want is to wait until the user pauses — say, 300ms — then act. That's debounce. Combined with autocomplete suggestions and arrow-key navigation, you've got a search bar that feels like a real product rather than a weekend side project.
Worth noting: if you're building a design-forward UI, check out Empire UI for pre-built components that already handle a lot of this wiring. But if you want to understand the mechanics, keep reading.
Debouncing Search Input the Right Way
The classic approach is setTimeout inside useEffect, cleaning up on the next render. Simple. Most tutorials show exactly this. But they often miss the cleanup, which means stale closures pile up and you get out-of-order results on fast typing.
Here's the pattern that actually works:
import { useState, useEffect } from 'react';
function useDebounce(value, delay = 300) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
function SearchBar({ onSearch }) {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 300);
useEffect(() => {
if (debouncedQuery) onSearch(debouncedQuery);
}, [debouncedQuery]);
return (
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
/>
);
}The useDebounce hook is reusable across your whole app — not just search. You'd use the same pattern for live form validation, filter panels, anything where you want to react to user input without hammering downstream logic on every frame.
Honestly, 300ms is the sweet spot for most UIs. Go lower and it feels twitchy; go above 500ms and users notice the lag. That said, if you're querying a remote API over a slow connection, bumping it to 400ms is completely reasonable.
Building the Autocomplete Dropdown
Debounce gets your search query under control. Autocomplete is where things get interesting. You need a suggestions list that appears below the input, stays in sync with keyboard state, and disappears at the right moments.
The state you need: the current query, the list of suggestions, which item is highlighted (an index), and whether the dropdown is open. That's it. Don't over-engineer this.
import { useState, useEffect, useRef } from 'react';
function SearchWithAutocomplete({ fetchSuggestions }) {
const [query, setQuery] = useState('');
const [suggestions, setSuggestions] = useState([]);
const [activeIndex, setActiveIndex] = useState(-1);
const [isOpen, setIsOpen] = useState(false);
const inputRef = useRef(null);
useEffect(() => {
if (!query.trim()) { setSuggestions([]); setIsOpen(false); return; }
const timer = setTimeout(async () => {
const results = await fetchSuggestions(query);
setSuggestions(results);
setIsOpen(results.length > 0);
setActiveIndex(-1);
}, 300);
return () => clearTimeout(timer);
}, [query]);
const handleKeyDown = (e) => {
if (!isOpen) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
setActiveIndex((i) => Math.min(i + 1, suggestions.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveIndex((i) => Math.max(i - 1, -1));
} else if (e.key === 'Enter' && activeIndex >= 0) {
e.preventDefault();
setQuery(suggestions[activeIndex]);
setIsOpen(false);
} else if (e.key === 'Escape') {
setIsOpen(false);
}
};
return (
<div style={{ position: 'relative' }}>
<input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={() => setTimeout(() => setIsOpen(false), 150)}
placeholder="Search..."
aria-autocomplete="list"
aria-expanded={isOpen}
/>
{isOpen && (
<ul role="listbox" style={{ position: 'absolute', top: '100%', left: 0, right: 0 }}>
{suggestions.map((s, i) => (
<li
key={s}
role="option"
aria-selected={i === activeIndex}
style={{ background: i === activeIndex ? '#e0e0e0' : '#fff', padding: '8px 12px', cursor: 'pointer' }}
onMouseDown={() => { setQuery(s); setIsOpen(false); }}
>
{s}
</li>
))}
</ul>
)}
</div>
);
}Notice the onBlur uses a 150ms delay before closing. Without that, clicking a suggestion fires blur before the click registers and the dropdown vanishes before the selection lands. That's one of those bugs that takes 20 minutes to track down the first time.
Keyboard Navigation and Accessibility
Keyboard nav isn't a nice-to-have. It's a baseline expectation in 2026. Screen reader users, power users, and anyone filling out a form from their keyboard all depend on it working.
The four keys you need to handle: ArrowDown (move highlight down), ArrowUp (move highlight up), Enter (select highlighted item), Escape (close dropdown). That's the whole contract. You saw them wired in the example above — preventDefault() on the arrow keys stops the cursor jumping inside the input, which is what you want.
ARIA matters here too. The role="listbox" on the ul and role="option" on each li, combined with aria-expanded and aria-selected, give screen readers enough context to announce what's happening. It's about 10 extra attributes and it dramatically changes the experience for a meaningful chunk of your users.
One more thing — make sure the highlighted option is visually distinct at minimum 3:1 contrast against the background. A 2px left border in your brand colour at around 40px tall works well and doesn't feel like you're shouting.
Styling Your Search Bar
Once the logic is solid, style is where the component becomes yours. The dropdown needs position: absolute on a position: relative parent, a high enough z-index (at least 100 to clear most layout stacking contexts), and a box-shadow so it reads as floating above the page content.
Quick aside: if you want to give your search bar a more distinctive look, the glassmorphism generator is a fast way to get a frosted-glass backdrop filter value you can drop straight into CSS. The box shadow generator is equally handy for getting that subtle lift right.
For the input itself, 48px height is the accessibility minimum for touch targets. Don't go below it. A 12px horizontal padding feels cramped; 16px reads as intentional. You'd also want to add a visible focus ring — outline: 2px solid in your primary colour, outline-offset: 2px — because removing it entirely breaks keyboard-only users completely.
Animations are optional but worth the small effort. A 150ms opacity and transform: translateY(-4px) to translateY(0) fade-in on the dropdown makes the UI feel considered rather than mechanical. Keep it under 200ms or it starts to feel slow.
Handling Edge Cases That Actually Come Up
Empty states. If your fetchSuggestions returns an empty array, don't render a dropdown at all — a floating empty box is confusing. The setIsOpen(results.length > 0) check in the example above handles this.
Race conditions. If a user types fast and two requests go out, the second might resolve before the first. You get stale results overwriting fresh ones. Fix: use a cleanup flag in your useEffect, or cancel the previous request using AbortController.
useEffect(() => {
let cancelled = false;
const timer = setTimeout(async () => {
const results = await fetchSuggestions(query);
if (!cancelled) {
setSuggestions(results);
setIsOpen(results.length > 0);
}
}, 300);
return () => { cancelled = true; clearTimeout(timer); };
}, [query]);Look, most tutorials skip this. But in any real app with a remote data source, you'll hit this within the first week of users hammering the search field. Worth adding it upfront rather than debugging weird result flicker later.
Putting It Together in a Real Component
You've got debounce, autocomplete, keyboard nav, and the edge cases covered. The last thing is to think about where this component lives in your app's architecture. Is it a standalone UI element? Part of a larger filter panel? Getting that boundary right determines how much internal state it owns versus what gets lifted up.
If the search bar is purely presentational — it shows suggestions and fires a callback when something's selected — keep all the dropdown state inside the component and only expose onSearch and onSelect as props. If it needs to coordinate with filters, sort order, or pagination, you'd probably want a parent component or a context holding the query state.
For teams building larger component systems, browse the components at Empire UI to see how search, filtering, and autocomplete patterns fit into a consistent design language. React hooks guides are also worth bookmarking if the useEffect cleanup patterns here sparked questions about other async patterns.
The fundamentals here — debounce, abort, ARIA, keyboard events — apply regardless of your styling choices. Nail the logic first. The visual layer is the easy part.
FAQ
300ms works for most cases — it's fast enough to feel responsive but slow enough to avoid hammering an API. If you're querying a remote endpoint, 350–400ms gives slower connections a bit more breathing room.
Use onMouseDown instead of onClick on the suggestion items. Mouse down fires before the input's blur event, so the selection registers before the dropdown closes.
For simple cases, building your own gives you full control and zero bundle cost. Downshift is worth it when you need complex accessibility patterns or your autocomplete has highly custom rendering requirements.
Use a cancelled flag in your useEffect cleanup, or use AbortController to cancel the in-flight fetch when a new request starts. Both patterns are shown in the edge cases section above.