Interview question
Debounce search and discard stale responses
Combines debounce cleanup, AbortController, and request identity to prevent stale search results.
TL;DR
Combines debounce cleanup, AbortController, and request identity to prevent stale search results.
Effects, cleanup, abort signals, debounce, stale responses, and minimum input rules.
Practice the problem like a real interview: restate, reason, implement, and test.
Implement a React search box backed by searchPeople(term, signal). Trim the query, wait 300 ms after the latest edit, and skip requests shorter than two characters. When the query changes, cancel the timer and abort any previous request. Because not every transport honors abort reliably, an older response must also be prevented from committing state after a newer query has become current.
The user types rea, pauses long enough to start request A, then types react, which starts request B. B resolves first and renders React results. A later resolves despite being aborted; its payload is ignored. Clearing the input immediately removes the results and status, and an aborted request never produces an error message.
AbortController and schedule the request after 300 ms.import { useEffect, useRef, useState } from "react";
type Person = { id: string; name: string };
type SearchStatus = "idle" | "waiting" | "loading" | "success" | "error";
type Props = {
searchPeople: (term: string, signal: AbortSignal) => Promise<Person[]>;
};
export function PeopleSearch({ searchPeople }: Props) {
const [query, setQuery] = useState("");
const [results, setResults] = useState<Person[]>([]);
const [status, setStatus] = useState<SearchStatus>("idle");
const [error, setError] = useState<string | null>(null);
const latestGeneration = useRef(0);
useEffect(() => {
const term = query.trim();
const generation = ++latestGeneration.current;
if (term.length < 2) {
setResults([]);
setError(null);
setStatus("idle");
return;
}
const controller = new AbortController();
setError(null);
setStatus("waiting");
const timer = window.setTimeout(async () => {
if (generation !== latestGeneration.current) return;
setStatus("loading");
try {
const people = await searchPeople(term, controller.signal);
if (generation !== latestGeneration.current) return;
setResults(people);
setStatus("success");
} catch (caught) {
const isAbort =
controller.signal.aborted ||
(caught instanceof DOMException && caught.name === "AbortError");
if (isAbort || generation !== latestGeneration.current) return;
setResults([]);
setError("Search failed. Try again.");
setStatus("error");
}
}, 300);
return () => {
window.clearTimeout(timer);
controller.abort();
};
}, [query, searchPeople]);
return (
<section aria-label="People search">
<label htmlFor="people-query">Search people</label>
<input
id="people-query"
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
aria-describedby="search-status"
/>
<p id="search-status" role="status" aria-live="polite">
{status === "waiting" ? "Waiting for typing to stop" : null}
{status === "loading" ? "Searching" : null}
{status === "success" ? `${results.length} results` : null}
{status === "error" ? error : null}
</p>
<ul>
{results.map((person) => <li key={person.id}>{person.name}</li>)}
</ul>
</section>
);
}
In a production application, a query library may own caching, deduplication, retries, and cancellation. The same interview reasoning still applies: normalize the key, debounce deliberately, and ensure only the current key may publish data.
Each keystroke does constant local work; rendering a successful result is O(n) for n people. Debouncing reduces request volume but intentionally adds 300 ms of latency after typing. Aborting can save browser and server work, but it is not a correctness guarantee because a response may already be complete or a custom transport may ignore the signal. Request identity is the final commit guard. Caching repeated terms can improve latency, but cache ownership belongs outside this small component once invalidation and deduplication matter.
searchPeople prop changes identity every render and unintentionally restarts the effect.Next in Frontend Labs: Optimistic Rollback