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
queryand an asynchronous fetcher function of the signature(query: string, signal?: AbortSignal) => Promise<T[]>. - Expose a unified state containing
data(results array),isLoading(boolean), anderror(Error or null). - Ensure that if
query Ais initiated, thenquery Bis initiated, the UI will always display results forquery B, even if the promise forquery Aresolves after the promise forquery 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
AbortControllerif 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
useEffectcleanup order and timing.AbortControllerinstantiation and signaling.useReffor 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 theAbortControllerAPI and how it interfaces withfetch. You will need to instantiate a new controller on each query change. - Hint 3: You can use a
useRefto 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 newAbortController, store it or clean it up in the effect cleanup, and pass its signal to the fetcher. Remember to handle theAbortErrorin 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
AbortControllerinstead 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
cacheTimeMsfromoptionsand add it to the dependency array. This prevents the hook from re-running if the user passes an inline configuration object likeuseAutocomplete(query, fetcher, { cacheTimeMs: 5000 })on every render.
- Clean Abort Strategy: It stops network transfer and releases thread resources immediately via
- 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
useCallbackfor 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
-
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
setTimeoutin a separateuseEffect, 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 theuseEffectscope (e.g.let active = true), toggling it tofalsein the cleanup function, and checkingif (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
setTimeoutinside the async block, ensuring that we pass the sameAbortSignalto each retry attempt and that all pending retries are canceled inside the effect cleanup.
