Build a Search with Debounce + API
Controlled input → debounced query → fetch with AbortController → results. Must handle: debouncing the derived value (not the input), cancelling stale requests to fix out-of-order races, min query length, loading/empty/error states, and not re-creating the debounced fn each render.
Search-with-debounce-and-API is the distilled version of the autocomplete problem — debounce + cancellation + states.
The implementation
function Search() {
const [query, setQuery] = useState("");
const [results, setResults] = useState([]);
const [status, setStatus] = useState("idle"); // idle|loading|success|empty|error
// debounce the DERIVED value, keep the input instant
const debouncedQuery = useDebouncedValue(query, 300);
useEffect(() => {
if (debouncedQuery.trim().length < 2) {
setResults([]); setStatus("idle");
return;
}
const controller = new AbortController();
setStatus("loading");
fetch(`/api/search?q=${encodeURIComponent(debouncedQuery)}`, { signal: controller.signal })
.then((r) => {
if (!r.ok) throw new Error("Search failed");
return r.json();
})
.then((data) => {
setResults(data);
setStatus(data.length ? "success" : "empty");
})
.catch((e) => { if (e.name !== "AbortError") setStatus("error"); });
return () => controller.abort(); // cancel stale request
}, [debouncedQuery]);
return (
<div>
<input value={query} onChange={(e) => setQuery(e.target.value)}
placeholder="Search…" aria-label="Search" />
{status === "loading" && <Spinner />}
{status === "error" && <button onClick={() => setQuery((q) => q + "")}>Retry</button>}
{status === "empty" && <p>No results</p>}
{status === "success" && <ul>{results.map((r) => <li key={r.id}>{r.name}</li>)}</ul>}
</div>
);
}
// the hook
function useDebouncedValue(value, delay) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id);
}, [value, delay]);
return debounced;
}What's being graded
- Debounce the derived value, not the input. The input stays controlled and instant;
debouncedQuerylags it and drives the fetch. Don't delaysetQuery— that makes typing feel laggy. - Cancel stale requests —
AbortController, aborted in the effect cleanup. Debounce reduces frequency; it does not stop an old slow response from resolving after a newer one and showing wrong results. Cancellation fixes that race. - Don't re-create the debounce each render — using the
useDebouncedValuehook (oruseMemo/useReffor a debounced fn) keeps it stable. A debounced function created in the render body never debounces. - Min query length — skip 0–1 char queries.
- All states — idle / loading / success / empty / error (with retry). Status enum, not a boolean.
- Encode the query, handle non-OK HTTP status (fetch doesn't reject on 404/500).
The framing
"Controlled input → a debounced derived value → a fetch effect with an AbortController. Three things I'd be sure to get right: debounce the derived value so the input itself stays instant; cancel stale requests in the effect cleanup, because debounce alone doesn't prevent an old slow response from overwriting a newer one; and keep the debounce stable — a debounced function created in render never actually debounces. Plus a min query length, a status enum covering loading/empty/error with retry, and encoding the query string."
Follow-up questions
- •Why doesn't debouncing alone prevent out-of-order responses?
- •Why debounce the derived value instead of the input's onChange?
- •What goes wrong if you create the debounced function inside render?
- •Why model status as an enum instead of an isLoading boolean?
Common mistakes
- •Firing a request on every keystroke.
- •Debouncing but not cancelling stale requests — out-of-order results.
- •Re-creating the debounced function each render so it never debounces.
- •Debouncing setQuery itself, making the input feel laggy.
- •Only handling success — no empty/error/loading.
Performance considerations
- •Debounce cuts request volume; cancellation avoids processing discarded responses and prevents wrong-result flicker; caching per query (or React Query) avoids refetching known queries.
Edge cases
- •An old query's slow response resolving after a newer one.
- •Query cleared while a request is in flight.
- •Zero results vs an error vs idle.
- •Special characters needing encodeURIComponent.
- •Component unmounts mid-request.
Real-world examples
- •Site search bars, documentation search, filter inputs.
- •React Query / SWR handling the debounce-fetch-cancel-cache pipeline in production.