How would you build a search with autocomplete in React?
Advertisement
🧩 Scenario
🧠 Architecture Walkthrough
Debouncing as a Separate Hook
The debounce logic lives in its own useDebounce hook rather than inside the component or the search callback. This separation matters because the debounce hook owns a single responsibility: converting a rapidly-changing value into a stable one after a quiet period.
If debounce were wired directly into the onChange handler, you would need to manage the timeout ref inside the component body and clear it carefully on unmount. By extracting it into useDebounce, the component only sees the debounced value and reacts to it in a useEffect.
This also makes the hook independently testable you can verify it delays values correctly without rendering any UI. The 300ms delay is a deliberate UX choice: fast enough to feel responsive, slow enough that a user typing a full word only triggers one or two requests instead of one per character.
Request Cancellation and the Race Condition Problem
Every time debouncedQuery changes, the search function needs to cancel the previous in-flight request before starting a new one. Without this, if the user types "San" and then immediately types "San F", two concurrent requests are in flight.
Whichever response arrives last wins even if it corresponds to the shorter query. The demo solves this with AbortController. A ref (abortControllerRef) holds the current controller. Before each new request, the previous controller is aborted, and a new one is created.
After receiving results, the code checks controller.signal.aborted before setting state. This guard prevents the bug where a cancelled request's results overwrite the results of a later request that already resolved. This is one of the most commonly missed details in autocomplete implementations.
Blur Delay and the Click-Before-Blur Problem
The handleBlur handler uses setTimeout(() => setIsOpen(false), 150). This 150ms window is not arbitrary it exists to solve a specific browser behavior. When a user clicks a suggestion in the dropdown, two events fire in sequence: mousedown on the suggestion, then blur on the input.
If blur immediately closed the dropdown, the click event on the suggestion would never fire because the element would be removed from the DOM before the click could be processed. The 150ms delay ensures the click event completes first.
This is a well-known pattern in autocomplete implementations and one that interviews often probe for. Removing the delay causes a subtle bug that only appears when users click suggestions rather than using the keyboard.
Client-Side Result Caching
The component maintains a cache object in state keyed by ${category}:${query}. Before firing a network request, it checks the cache for a hit. If one exists, it populates suggestions synchronously and skips the loading state entirely.
This matters in practice because users frequently backspace and retype similar queries in a search session. Without caching, "san" → "san francisco" → backspace to "san" would trigger three network calls; with caching, the third query is free.
The cache lives in component state rather than a ref because it needs to trigger re-renders when populated. One thing to watch in production: this cache is unbounded and lives as long as the component is mounted. For long-lived components, you would want an LRU eviction strategy or a shared cache layer like React Query.
💡 Key Code Explained
const useDebounce = (value, delay) => {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => clearTimeout(handler);
}, [value, delay]);
return debouncedValue;
};
The cleanup function return () => clearTimeout(handler) is the entire mechanism behind debouncing. Every time value changes, the effect runs, sets a new timer, and the cleanup from the previous render cancels the old one.
Only the last timer set after the user stops typing survives long enough to fire. A junior developer often writes the debounce logic directly inside an onChange handler using a module-level timeout variable.
That approach breaks in React because it does not account for re-renders resetting closure state, and it leaks the timeout when the component unmounts.
const search = useCallback(
async (searchQuery) => {
const cacheKey = `${category}:${searchQuery.toLowerCase()}`;
if (cache[cacheKey]) {
setSuggestions(cache[cacheKey]);
setIsOpen(true);
setLoading(false);
return;
}
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
const controller = new AbortController();
abortControllerRef.current = controller;
setLoading(true);
setError(null);
try {
const results = await simulateAPI(searchQuery, category);
if (!controller.signal.aborted) {
setSuggestions(results);
setIsOpen(true);
setCache((prev) => ({ ...prev, [cacheKey]: results }));
}
} catch (err) {
if (!controller.signal.aborted) {
setError(err.message);
setSuggestions([]);
}
} finally {
if (!controller.signal.aborted) {
setLoading(false);
}
}
},
[category, cache],
);
Notice that cache is in the useCallback dependency array. This means a new search function reference is created whenever the cache is updated, which in turn triggers the useEffect that watches search.
This creates a subtle re-execution loop that is avoided because debouncedQuery is also in that effect's dependencies and debouncedQuery has not changed, so the effect re-runs search with the same query, which now hits the cache immediately and returns.
The pattern works correctly, but it is worth understanding: useCallback with mutable state in the deps array means the callback itself changes over time, which can have downstream effects on any effects that depend on it.
const handleBlur = () => {
setTimeout(() => {
setIsOpen(false);
setActiveIndex(-1);
}, 150);
};
This 150ms delay is the minimum needed to let a click event on a dropdown item fully register before the dropdown is destroyed. The browser fires events in this order: mousedown → blur → mouseup → click.
The blur fires before click, so without the delay, setIsOpen(false) would unmount the dropdown list before the onClick handler on the list item could fire.
150ms is enough for this event sequence to complete on all major browsers. Setting it higher (e.g., 500ms) would make the dropdown feel sticky. Setting it to 0 via setTimeout(..., 0) is not reliable on all browsers and can still lose the click on slower devices.
⚖️ Tradeoffs
| Approach | Pro | Con |
|---|---|---|
| Debounce in a custom hook | Reusable, testable, clean separation of concerns | Adds a render cycle between input change and search trigger |
| Debounce inside onChange handler | Fewer moving parts, easier to trace | Ties debounce to input event, harder to test, leaks on unmount if not cleaned |
| Throttle instead of debounce | Guarantees at least one request per interval (useful for type-ahead feel) | Does not wait for typing to stop; can fire on intermediate characters |
| AbortController for cancellation | Native browser API, clean signal propagation | Not available in IE11; older codebases may use a boolean isCancelled flag ref instead |
| Component-level cache in state | Simple, no external dependencies | Unbounded growth, lost on unmount, no sharing across component instances |
| React Query or SWR for caching | Persistent across mounts, deduplication, background refetch | Adds dependency, more setup for a self-contained component |
🎯 What Interviewers Actually Check
- Whether you identify the blur-before-click race condition without being prompted most candidates miss this
- Whether you reach for AbortController or a boolean
cancelledflag, and whether you know the difference in safety guarantees between the two - Whether you reset
activeIndexto -1 on every new input change, not just on selection or close - Whether you scroll the active suggestion into view when navigating with the keyboard the demo uses
scrollIntoViewin auseEffect - Whether your ARIA attributes (
role="combobox",aria-expanded,aria-activedescendant) are placed on the correct elements and updated correctly as state changes
❓ Follow-Up Questions
- The demo stores cache in component state, which means it is lost when the component unmounts. How would you share the cache across multiple
Autocompleteinstances on the same page without introducing a global variable? - What happens if the user types quickly enough that two debounced searches fire almost simultaneously say 280ms and 310ms apart? Does the AbortController guarantee the correct results are displayed, or is there still a possible race?
- How would you write a unit test for the blur-delay click-selection behavior? What would you need to mock or fake-timer?
- If this autocomplete receives 50,000 suggestions from the API and renders them all in the DOM, what performance problems would occur and how would you fix them?
- Your PM says users on mobile complain the dropdown closes when they try to scroll it. What is causing this and how do you fix it without breaking the blur-close behavior on desktop?
🎮 Live Demo
📝 Summary
Building a production-grade autocomplete requires solving three distinct problems simultaneously: timing (debounce prevents API spam), correctness (AbortController prevents stale results), and interaction (the blur delay prevents the dropdown from vanishing before click events register).
Each of these feels like a minor detail until it breaks in production users who lose their selection when clicking, or see results from a previous query flash momentarily, notice immediately. The caching layer converts the component from something that fires on every query into something that rewards repeat interactions with instant responses, which has an outsized impact on perceived performance.
For a senior frontend role, the expectation is not just that you can implement the feature, but that you can name the specific failure modes blur-before-click race, stale request overwrite, cache growth and explain the exact code pattern that prevents each one.
Why use debouncing?
Debouncing prevents unnecessary API calls by waiting for the user to finish typing.
How do you support keyboard navigation?
Track active index and respond to ArrowUp/ArrowDown/Enter keys.
Advertisement
Stay Updated
Get the latest frontend challenges, interview questions and tutorials delivered to your inbox.
Advertisement