New: We've launched a brand new Coding Challenges section! Check out these interactive, real-world exercises to level up your skills.Explore Challenges
FrontendPrep
reactHard

Autocomplete Search Hook with Race-Condition Prevention

Loading...

Implement a reusable useAutocomplete custom React hook with AbortController, cache deduplication, and full lifecycle cleanup to prevent async race conditions.

Arvind M
Arvind MLinkedIn

Title: Autocomplete Search Hook with Race-Condition Prevention Category: React Difficulty: Advanced Expected Time: 45 mins

Problem Statement

We are building an autocomplete search input for our product catalog. When users type quickly, we fire off API requests. Because of network latency variation, these requests often resolve out of order. We have had bugs where users see results for a query they typed three characters ago instead of their current input.

I want you to build a custom React hook called useAutocomplete that takes a query string and a fetcher function. It should manage the state of the search results, loading status, and errors. Crucially, it must handle race conditions so that only the results of the latest query are ever rendered. It also needs to cache previous queries to prevent duplicate network requests, and clean up any active network requests if the component unmounts or if the query changes before the previous request finishes.

What to Focus On

The interviewer is looking for how clean your cleanup strategy is: specifically, how you use AbortController to cancel in-flight requests rather than just ignoring their resolution, and how you prevent state updates on unmounted components without relying on stale isMounted ref anti-patterns.

Requirements

Functional:

  • The hook must accept a string query and an asynchronous fetcher function of the signature (query: string, signal?: AbortSignal) => Promise<T[]>.
  • Expose a unified state containing data (results array), isLoading (boolean), and error (Error or null).
  • Ensure that if query A is initiated, then query B is initiated, the UI will always display results for query B, even if the promise for query A resolves after the promise for query B.
  • Implement a local client-side cache mapping queries to results. If a query exists in the cache, return the cached data immediately and skip the fetcher call.
  • Cancel any ongoing fetch request using an AbortController if the query changes or if the component using the hook unmounts.
  • Do not call the fetcher if the query is empty or contains only whitespace. In that case, reset the results to an empty array and clear loading/error states.

Non-Functional:

  • Avoid unnecessary re-runs of the main fetching logic if other props or parent components update.
  • Write the hook in TypeScript with strict typing.
  • Prevent memory leaks by properly aborting HTTP fetches at the network layer.

Concepts Tested

  • useEffect cleanup order and timing.
  • AbortController instantiation and signaling.
  • useRef for persistent mutable values (cache storage).
  • Async error handling (distinguishing user-triggered AbortError from real API failures).
  • React rendering lifecycle and stale closure prevention.

Hints

  • Hint 1: Think about how React cleanups work inside useEffect. When a hook's dependencies change, React runs the cleanup function of the previous render before running the effect for the new render. How can you use this to flag a request as outdated?
  • Hint 2: To abort the actual network request rather than just ignoring its result, you need a way to pass a signal to your fetcher. Look into the AbortController API and how it interfaces with fetch. You will need to instantiate a new controller on each query change.
  • Hint 3: You can use a useRef to store your query cache so that it persists across re-renders without triggering new renders. When a query is made, check this cache ref first. If it is a hit, update the state synchronously. Otherwise, initialize a new AbortController, store it or clean it up in the effect cleanup, and pass its signal to the fetcher. Remember to handle the AbortError in your catch block so you do not treat cancellations as actual search errors.

Solution

Explanation

To solve the race condition and avoid stale states, we make use of the useEffect cleanup function. Since useEffect cleanup executes whenever dependencies change or the component unmounts, it is the perfect place to signal cancellation for any asynchronous operation currently in flight.

Using the web standard AbortController, we instantiate a new controller instance inside the effect block for every fresh query search. We pass abortController.signal to the fetcher function. If the user types a new character before the fetch completes, the cleanup function runs, calling abortController.abort().

To support native fetch cancellation, the fetcher function must forward the AbortSignal to the browser's fetch API. When aborted, the fetch promise rejects with a DOMException named AbortError. In the hook's catch block, we check for this error name and explicitly return early, ignoring the rejection. This prevents setting error states or triggering loading changes for aborted queries.

We use a useRef pointing to a JavaScript Map to maintain the cache dictionary. Because refs do not trigger re-renders when updated, it acts as a silent, persistent lookup table. We check this cache synchronously at the start of our effect. If we find a hit, we apply the results directly and skip initializing the AbortController.

Code

import { useState, useEffect, useRef } from "react";
 
interface AutocompleteState<T> {
  data: T[];
  isLoading: boolean;
  error: Error | null;
}
 
interface AutocompleteOptions {
  cacheTimeMs?: number;
}
 
export function useAutocomplete<T>(
  query: string,
  fetcher: (query: string, signal?: AbortSignal) => Promise<T[]>,
  options: AutocompleteOptions = {}
): AutocompleteState<T> {
  const [state, setState] = useState<AutocompleteState<T>>({
    data: [],
    isLoading: false,
    error: null,
  });
 
  // Store cache in a ref to persist across renders without causing updates
  const cacheRef = useRef<Map<string, { data: T[]; timestamp: number }>>(
    new Map()
  );
 
  const { cacheTimeMs } = options;
 
  useEffect(() => {
    const trimmedQuery = query.trim();
 
    // 1. Handle empty input state
    if (!trimmedQuery) {
      setState({ data: [], isLoading: false, error: null });
      return;
    }
 
    // 2. Check cache hit and expiration
    const cachedEntry = cacheRef.current.get(trimmedQuery);
    if (cachedEntry) {
      const isExpired = cacheTimeMs && (Date.now() - cachedEntry.timestamp > cacheTimeMs);
      if (!isExpired) {
        setState({ data: cachedEntry.data, isLoading: false, error: null });
        return;
      }
    }
 
    // 3. Setup AbortController for concurrent request cancellation
    const abortController = new AbortController();
 
    setState((prev) => ({ ...prev, isLoading: true, error: null }));
 
    async function executeFetch() {
      try {
        const results = await fetcher(trimmedQuery, abortController.signal);
 
        // 4. Save successful results to cache
        cacheRef.current.set(trimmedQuery, {
          data: results,
          timestamp: Date.now(),
        });
 
        // 5. Update state (only executes if the request wasn't aborted)
        setState({
          data: results,
          isLoading: false,
          error: null,
        });
      } catch (err: any) {
        // Ignore AbortErrors. The signal has been canceled, so we want no state modifications.
        if (err.name === "AbortError" || abortController.signal.aborted) {
          return;
        }
 
        setState({
          data: [],
          isLoading: false,
          error: err instanceof Error ? err : new Error("An unexpected error occurred"),
        });
      }
    }
 
    executeFetch();
 
    // 6. Clean up: cancel the fetch in progress if query changes or component unmounts
    return () => {
      abortController.abort();
    };
  }, [query, fetcher, cacheTimeMs]);
 
  return state;
}

Trade-offs

  • What this solution does well:
    • Clean Abort Strategy: It stops network transfer and releases thread resources immediately via AbortController instead of letting requests resolve and discarding them in JavaScript memory.
    • Synchronous Cache Recovery: Cache lookups occur synchronously, preventing intermediate loading flashes when typing queries that have already been resolved.
    • Targeted Dependency Array: We destructure cacheTimeMs from options and add it to the dependency array. This prevents the hook from re-running if the user passes an inline configuration object like useAutocomplete(query, fetcher, { cacheTimeMs: 5000 }) on every render.
  • What could be improved at scale:
    • Memory Growth: The cache ref grows indefinitely since there is no mechanism to evict old queries (e.g. an LRU cache or max size limit).
    • Fetcher Reference Stability: If the caller passes a non-memoized fetcher function, the hook will re-run the effect on every render. We assume the caller uses useCallback for the fetcher, but we could make the hook more resilient by wrapping the fetcher inside a mutable ref (const fetcherRef = useRef(fetcher)).

Interview Follow-Ups

  1. How would you implement debouncing within this hook so we do not fire requests on every single keystroke? → Strong answers mention: establishing a debounced query state variable inside the hook using setTimeout in a separate useEffect, and using that debounced value as the primary dependency for the fetching effect.

  2. What happens if two different components call this hook with the exact same query simultaneously? How would you share the in-flight promise? → Strong answers mention: moving the in-flight fetch records to a module-scoped cache map or React Context, keeping track of active promises, and having multiple hooks hook into the same promise instead of launching separate requests.

  3. Our fetcher uses a third-party SDK that does not support passing an AbortSignal. How do you prevent the race condition in this scenario? → Strong answers mention: keeping a local boolean flag inside the useEffect scope (e.g. let active = true), toggling it to false in the cleanup function, and checking if (active) before calling state setters.

  4. How would you handle caching at scale? What strategy would you use to prevent the cache from growing indefinitely and leaking memory? → Strong answers mention: implementing a Least Recently Used (LRU) cache system or a simple cleanup interval that evicts keys when the size exceeds a set threshold (e.g., 100 items).

  5. If a query fails, how would you implement an automatic retry mechanism with exponential backoff while still ensuring race conditions are resolved? → Strong answers mention: scheduling retries recursively using setTimeout inside the async block, ensuring that we pass the same AbortSignal to each retry attempt and that all pending retries are canceled inside the effect cleanup.

Finished practicing this challenge?

Mark it as completed to track your progress, or bookmark it to review later.

Loading...

Share this Resource

Help other developers level up by sharing this study guide.

⚡ Weekly newsletter

Crack Your Next Frontend Interview.

Join senior engineers who receive practical, deep-dive frontend challenges, detailed concepts, and blueprints directly in their inbox.

  • Senior level React, JS, and CSS interview blueprints
  • System Design & performance optimization deep-dives
  • 100% free, zero spam, unsubscribe with one click

Join the Study Track

We value your privacy. Unsubscribe at any time.

More Technical Questions

Expand your mastery. Deep dive into other frontend interview challenges in this category.