Building a Search-as-You-Type Combobox in React
Building a Search-as-You-Type Combobox in React
A search-as-you-type combobox is a great frontend pattern because it combines fast filtering, cancellation-safe data fetching, and keyboard accessibility in one UI. This guide walks through a production-friendly implementation with debouncing, AbortController, ARIA combobox semantics, and optimistic selection handling.
Why this pattern matters
Typeahead inputs are deceptively hard because users can type faster than your network can respond. If you fetch on every keystroke without guarding against race conditions, stale responses can overwrite newer ones, which makes the UI feel broken.
The ARIA combobox pattern is the right accessibility foundation for this interface, and the WAI APG describes keeping DOM focus on the combobox while moving assistive-technology focus through the listbox with aria-activedescendant.
What you will build
The finished component will do four things well:
- Wait briefly before sending a request, so you do not hammer the API on every keypress.
- Cancel in-flight requests when the query changes.
- Support arrow keys, Enter, Escape, and screen readers correctly.
- Keep selection state responsive, even while data is still loading.
Core structure
Start with a small state model. You want to separate the raw input text, the debounced query, the fetched options, the active option index, and the selected value.
import { useEffect, useMemo, useRef, useState } from "react";
type Result = {
id: string;
label: string;
};
export function SearchCombobox() {
const [query, setQuery] = useState("");
const [debouncedQuery, setDebouncedQuery] = useState("");
const [results, setResults] = useState<Result[]>([]);
const [activeIndex, setActiveIndex] = useState(-1);
const [selected, setSelected] = useState<Result | null>(null);
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const abortRef = useRef<AbortController | null>(null);
const listboxId = "search-listbox";
const inputId = "search-input";
This separation matters because the text the user is typing is not always the same as the query you send to the server. Debouncing the request state, not the input state, keeps typing snappy while still protecting your backend.
Add debouncing
A lightweight debounce can be built with useEffect and setTimeout. The key is to clear the timer every time the user types again, then update the debounced query only after a short pause.
useEffect(() => {
const t = setTimeout(() => {
setDebouncedQuery(query.trim());
}, 250);
return () => clearTimeout(t);
}, [query]);
A delay around 200-300ms is a practical starting point for fast APIs, while slower APIs may feel better around 350ms. The right number depends on your product, but avoiding a fixed 500ms delay usually makes the UI feel less sluggish.
Fetch safely
When the debounced query changes, abort the previous request before starting a new one. MDN documents that AbortController.abort() cancels fetch() and causes the promise to reject with an AbortError.
useEffect(() => {
if (!debouncedQuery || debouncedQuery.length < 2) {
setResults([]);
setOpen(false);
setLoading(false);
abortRef.current?.abort();
return;
}
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setLoading(true);
fetch(`/api/search?q=${encodeURIComponent(debouncedQuery)}`, {
signal: controller.signal,
})
.then((res) => res.json())
.then((data: Result[]) => {
setResults(data);
setActiveIndex(data.length ? 0 : -1);
setOpen(true);
})
.catch((err) => {
if (err?.name !== "AbortError") {
console.error(err);
}
})
.finally(() => {
if (!controller.signal.aborted) setLoading(false);
});
return () => controller.abort();
}, [debouncedQuery]);
That pattern prevents stale responses from older keystrokes from replacing newer data. If your API cannot be cancelled, a request-ID strategy can provide the same “latest request wins” behavior.
Make it accessible
The combobox input should expose the right ARIA state so assistive technology understands it. The APG combobox pattern uses role="combobox", aria-expanded, aria-controls, and listbox options driven by aria-activedescendant.
const activeOptionId =
activeIndex >= 0 ? `search-option-${results[activeIndex]?.id}` : undefined;
return (
<div style={{ position: "relative", maxWidth: 480 }}>
<label htmlFor={inputId}>Search products</label>
<input
id={inputId}
role="combobox"
aria-autocomplete="list"
aria-expanded={open}
aria-controls={listboxId}
aria-activedescendant={activeOptionId}
value={query}
onChange={(e) => {
setQuery(e.target.value);
setOpen(true);
}}
onFocus={() => {
if (results.length) setOpen(true);
}}
onKeyDown={(e) => {
if (!open && (e.key === "ArrowDown" || e.key === "ArrowUp")) {
setOpen(true);
return;
}
if (e.key === "ArrowDown") {
e.preventDefault();
setActiveIndex((i) => Math.min(i + 1, results.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActiveIndex((i) => Math.max(i - 1, 0));
} else if (e.key === "Enter") {
if (activeIndex >= 0 && results[activeIndex]) {
e.preventDefault();
setSelected(results[activeIndex]);
setQuery(results[activeIndex].label);
setOpen(false);
}
} else if (e.key === "Escape") {
setOpen(false);
}
}}
/>
Keeping focus on the input while tracking the active option with aria-activedescendant is the most robust pattern for keyboard users and screen readers.
Render the listbox
The popup should behave like a listbox with options, and the active option should be visually distinct. That makes the keyboard state obvious and reduces the chance of selecting the wrong item.
{open && (loading || results.length > 0) && (
<ul
id={listboxId}
role="listbox"
style={{
position: "absolute",
zIndex: 10,
margin: 0,
padding: 0,
listStyle: "none",
border: "1px solid #ccc",
width: "100%",
background: "white",
maxHeight: 240,
overflowY: "auto",
}}
>
{loading && <li style={{ padding: 12 }}>Loading…</li>}
{!loading &&
results.map((item, index) => (
<li
key={item.id}
id={`search-option-${item.id}`}
role="option"
aria-selected={index === activeIndex}
onMouseEnter={() => setActiveIndex(index)}
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
setSelected(item);
setQuery(item.label);
setOpen(false);
}}
style={{
padding: 12,
cursor: "pointer",
background: index === activeIndex ? "#eef4ff" : "white",
}}
>
{item.label}
</li>
))}
</ul>
)}
{selected && (
<p style={{ marginTop: 12 }}>
Selected: <strong>{selected.label}</strong>
</p>
)}
</div>
);
}
Using onMouseDown to prevent blur is a small but important detail because clicking an option should not close the menu before the click is handled. That kind of interaction polish is what makes a combobox feel reliable.
Handle race conditions
Even with AbortController, it is smart to think about race conditions as a design problem, not just an API problem. The most durable approach is “abort the old request, ignore anything stale, and only commit the latest response.”
A request-ID fallback is useful when cancellation is unavailable:
const requestIdRef = useRef(0);
useEffect(() => {
const id = ++requestIdRef.current;
async function run() {
const res = await fetch(`/api/search?q=${encodeURIComponent(debouncedQuery)}`);
const data = await res.json();
if (id === requestIdRef.current) {
setResults(data);
setOpen(true);
}
}
run();
}, [debouncedQuery]);
This guards the UI even if a slow network response arrives after a newer one.
Improve UX details
A few small touches make the component feel much more polished:
- Clear results immediately for empty input or whitespace-only input.
- Require a minimum length, such as two characters, before searching.
- Announce result counts in a live region if the list is dynamic.
- Close on Escape and keep arrow-key navigation predictable.
- Preserve the selected value separately from the current query so users can edit without losing context.
A live region is especially useful when the list updates quickly, because users of assistive technologies should know whether they have 0 results, 5 results, or a loading state. The APG pattern and combobox examples both emphasize making the active state and popup behavior clear to non-visual users.
Production checklist
Before shipping, verify these points:
- Keyboard navigation works without a mouse.
- Screen readers announce the input as a combobox and the popup as a listbox.
- Old requests are cancelled or ignored.
- Empty and short queries do not spam the API.
- The selected result is visibly and logically separate from the current typed text.
If you later move this pattern into a data layer such as TanStack Query, optimistic updates and rollback support can make selection and mutation flows feel instant while still keeping server truth intact.
A practical example
Imagine a product search field on an ecommerce homepage. The user types “he”, sees five matching items, presses ArrowDown twice, then Enter; the input updates to the selected product name, and the menu closes immediately. If the user keeps typing “head”, the old fetch is aborted, and only the newest response can update the list.
That sequence is exactly why this pattern is worth learning: it combines speed, correctness, and accessibility in one reusable component.
-
Rizwan Saleem | https://rizwansaleem.co
Top comments (0)