66Build search-as-you-type with debounce and AbortController
Problem
Build a <Search searchApi={searchApi} delay={300} /> component. searchApi(query, { signal }) returns a promise of an array of strings.
It renders:
- an
<input aria-label="Search">; <p role="status">Searching…</p>while a request is in flight;- the results as
<ul><li>…</li></ul>, or<p>No results</p>when a finished search found nothing; <p role="alert">Something went wrong</p>when the request fails.
<Search searchApi={(q, { signal }) => fetch(`/api/search?q=${encodeURIComponent(q)}`, { signal }).then((r) => r.json())} />Requirements
- Debounce: only search after the user has stopped typing for
delayms. Typing "react" quickly sends one request. - Trim the query; an empty query clears the results and sends nothing.
- When a new search starts, abort the previous in-flight request, and never show results for an older query.
- Keep the input responsive at all times.
Try it yourself
Write your solution, run it, then submit to check it against the tests.
Step by step
Build the solution one idea at a time.
- 1
Separate what the user types from what you search
Keep
queryin state and update it on every keystroke, so the input stays responsive. Derive a debounced value from it with a timer in an effect (or reuse auseDebouncehook): a new keystroke clears the previous timer. - 2
Search in an effect keyed on the debounced query
When the debounced, trimmed query changes, run the search in an effect. For an empty query, clear the results and stop.
- 3
One AbortController per search
Create a controller in the effect, pass
{ signal }tosearchApi, and abort it in the effect cleanup. React runs the cleanup when the debounced query changes again or the component unmounts, so only the latest request survives. - 4
Track status explicitly
Use a small status value:
"idle","loading","success"or"error". It makes the UI easy to read and avoids impossible combinations such as "loading and error". - 5
Ignore aborts, guard late results
In
catch, return early if the signal was aborted: that is a cancellation, not an error. Before setting results, check!signal.aborted, so a response that slipped in just before the abort cannot overwrite newer results.
Approach
Two effects: one debounces the typed text, the other runs a cancellable search for the debounced text. Each search owns an AbortController that its cleanup aborts, so stale requests are cancelled and stale responses ignored.
Solution
import { useEffect, useState } from "react";
function Search({ searchApi, delay = 300 }) {
const [query, setQuery] = useState("");
const [debounced, setDebounced] = useState("");
const [results, setResults] = useState([]);
const [status, setStatus] = useState("idle");
useEffect(() => {
const timer = setTimeout(() => setDebounced(query.trim()), delay);
return () => clearTimeout(timer);
}, [query, delay]);
useEffect(() => {
if (!debounced) {
setResults([]);
setStatus("idle");
return;
}
const controller = new AbortController();
setStatus("loading");
searchApi(debounced, { signal: controller.signal }).then(
(items) => {
if (controller.signal.aborted) return;
setResults(items);
setStatus("success");
},
() => {
if (controller.signal.aborted) return;
setStatus("error");
},
);
return () => controller.abort();
}, [debounced, searchApi]);
return (
<div>
<input aria-label="Search" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search fruits" />
{status === "loading" && <p role="status">Searching…</p>}
{status === "error" && <p role="alert">Something went wrong</p>}
{status === "success" && results.length === 0 && <p>No results</p>}
{status === "success" && results.length > 0 && (
<ul>
{results.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
)}
</div>
);
}
// Preview: a fake API with a random delay.
const FRUITS = ["apple", "apricot", "banana", "blackberry", "blueberry", "cherry", "grape", "mango", "melon", "orange", "peach", "pear"];
function fakeSearch(query, { signal }) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => resolve(FRUITS.filter((f) => f.includes(query.toLowerCase()))), 200 + Math.random() * 400);
signal.addEventListener("abort", () => { clearTimeout(timer); reject(new DOMException("Aborted", "AbortError")); });
});
}
function App() {
return <Search searchApi={fakeSearch} />;
}Complexity
Time: one request per pause in typing. Space: O(results).
Edge cases
- Typing quickly sends a single request for the final text.
- Clearing the input clears the results without a request.
" react "and"react"are the same search.- A slow response for an old query is aborted and can never replace newer results.
- An aborted request is not shown as an error.
Follow-up questions
Answer each one out loud first, then reveal the model answer.