How would you build a search with autocomplete in React?

Advanced20 min interview
Skills tested:
Debouncing StrategiesAsync Request ManagementKeyboard NavigationAccessibilityClient-Side CachingState Synchronization

Advertisement

🧩 Scenario

Your team needs a Google-style autocomplete component that supports: - Debounced API requests - Async suggestion loading - Keyboard navigation - Click and keyboard selection - Loading and empty states - Request cancellation - Accessible ARIA interactions The component should remain responsive under rapid typing and unreliable network conditions.

🧠 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: mousedownblurmouseupclick.

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

ApproachProCon
Debounce in a custom hookReusable, testable, clean separation of concernsAdds a render cycle between input change and search trigger
Debounce inside onChange handlerFewer moving parts, easier to traceTies debounce to input event, harder to test, leaks on unmount if not cleaned
Throttle instead of debounceGuarantees 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 cancellationNative browser API, clean signal propagationNot available in IE11; older codebases may use a boolean isCancelled flag ref instead
Component-level cache in stateSimple, no external dependenciesUnbounded growth, lost on unmount, no sharing across component instances
React Query or SWR for cachingPersistent across mounts, deduplication, background refetchAdds 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 cancelled flag, and whether you know the difference in safety guarantees between the two
  • Whether you reset activeIndex to -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 scrollIntoView in a useEffect
  • 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

  1. The demo stores cache in component state, which means it is lost when the component unmounts. How would you share the cache across multiple Autocomplete instances on the same page without introducing a global variable?
  2. 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?
  3. How would you write a unit test for the blur-delay click-selection behavior? What would you need to mock or fake-timer?
  4. 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?
  5. 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.

Frequently Asked Questions

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