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

Mark it as completed to track your progress, or bookmark it to review later.
Join senior engineers who receive practical, deep-dive frontend challenges, detailed concepts, and blueprints directly in their inbox.
We value your privacy. Unsubscribe at any time.
Expand your mastery. Deep dive into other frontend interview challenges in this category.
Understand React.forwardRef. Learn how to pass refs from parent components down to custom child components or native DOM elements effectively.
Understand the historical shift from Class components to Functional components with Hooks in React. Compare their syntax, lifecycle methods, and performance implications.
Title: Autocomplete Search Hook with Race-Condition Prevention Category: React Difficulty: Advanced Expected Time: 45 mins
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.
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.
query and an asynchronous fetcher function of the signature (query: string, signal?: AbortSignal) => Promise<T[]>.data (results array), isLoading (boolean), and error (Error or null).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.AbortController if the query changes or if the component using the hook unmounts.useEffect cleanup order and timing.AbortController instantiation and signaling.useRef for persistent mutable values (cache storage).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?fetcher. Look into the AbortController API and how it interfaces with fetch. You will need to instantiate a new controller on each query change.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.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.
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<
AbortController instead of letting requests resolve and discarding them in JavaScript memory.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.useCallback for the fetcher, but we could make the hook more resilient by wrapping the fetcher inside a mutable ref (const fetcherRef = useRef(fetcher)).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.
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.
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.
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).
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.