Build a debounced search box
Controlled input + debounced effect: store the raw input in state, run a debounced effect that fires the search N ms after typing stops. Cancel in-flight requests on new input (AbortController), handle race conditions (ignore stale responses), show loading/empty/error states, and make the listbox accessible.
A debounced search box is the canonical "you understand effects, timing, and race conditions" challenge.
1. Controlled input + debounced effect
function SearchBox() {
const [query, setQuery] = useState("");
const [results, setResults] = useState([]);
const [status, setStatus] = useState("idle"); // idle | loading | error
useEffect(() => {
if (!query.trim()) { setResults([]); setStatus("idle"); return; }
const controller = new AbortController();
const t = setTimeout(async () => {
setStatus("loading");
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`, { signal: controller.signal });
const data = await res.json();
setResults(data);
setStatus("idle");
} catch (e) {
if (e.name !== "AbortError") setStatus("error");
}
}, 300);
return () => { clearTimeout(t); controller.abort(); };
}, [query]);
return (
<div>
<input value={query} onChange={e => setQuery(e.target.value)} aria-label="Search" />
{status === "loading" && <Spinner />}
{status === "error" && <p role="alert">Something went wrong.</p>}
<ul role="listbox">{results.map(r => <li key={r.id} role="option">{r.name}</li>)}</ul>
</div>
);
}2. Why debounce in the effect, not the handler
Debouncing in the handler with useCallback(debounce(...)) is fragile — refs get stale, the deps array gets weird. Putting the timer inside the effect with the query in deps is the cleanest pattern: each keystroke schedules a new run; the cleanup cancels the previous one. Same with the AbortController — each effect owns its own request and cancels it on cleanup.
3. Race conditions
Even with debouncing, a slow response from query "ja" might land after the response for "java". AbortController is the right fix — old requests are cancelled before they resolve. A backup pattern is a "latest query" ref and ignore-if-stale check.
4. UX details
- Min length before firing (often 2 chars).
- Trim before checking.
- Empty state — "Type to search."
- No results — distinguish from idle and loading.
- Error state with retry.
- Clear button that resets query and focuses the input.
5. Accessibility — combobox pattern
For an autocomplete dropdown:
role="combobox"witharia-expanded,aria-controls,aria-autocomplete="list".- Listbox of options,
aria-activedescendantfor current highlight. - Keyboard: ArrowDown / ArrowUp to navigate, Enter to select, Escape to close.
- Announce result count via a polite live region.
6. The polish
- Cache previous queries (
Map<query, results>) or use React Query with the query as the key — instant on revisit. - Show recent / popular searches when empty.
- Highlight matching substrings in results.
Interview framing
"Controlled input feeding a debounced effect: the input updates state on every keystroke, an effect on [query] schedules a setTimeout for ~300ms; cleanup clears the timer and aborts the in-flight request. The AbortController kills the race condition where a slow earlier response would overwrite a newer one. Then UX states: idle, loading, empty, error — and a combobox ARIA shell if it's a dropdown autocomplete."
Follow-up questions
- •Why debounce inside the effect instead of wrapping the handler?
- •How does AbortController prevent race conditions?
- •What ARIA roles does an autocomplete need?
- •How would you cache previous queries?
Common mistakes
- •No abort — slow earlier response overwrites a newer one.
- •Debouncing in the handler with stale closures over state.
- •No min-length / trim — firing on empty.
- •Missing loading/empty/error states.
- •No keyboard navigation for the result list.
Performance considerations
- •Debounce caps request rate (~3/sec at 300ms). Aborts free server resources too. Caching turns revisits into 0-latency.
Edge cases
- •Very fast typing followed by stop — make sure the final query fires.
- •Network error mid-typing.
- •Backend echoing a different query than requested (verify or trust).
- •Unmount during a pending fetch — abort.
Real-world examples
- •Google / Algolia search-as-you-type.
- •Slack channel/user search.
- •Linear command palette.