Back to Machine Coding
Machine Coding
easy
mid

How would you build a search feature with debounce and live API calls?

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.

4 min read·~20 min to think through

Search-with-debounce-and-API is the distilled version of the autocomplete problem — debounce + cancellation + states.

The implementation

jsx
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

  1. Debounce the derived value, not the input. The input stays controlled and instant; debouncedQuery lags it and drives the fetch. Don't delay setQuery — that makes typing feel laggy.
  2. Cancel stale requestsAbortController, 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.
  3. Don't re-create the debounce each render — using the useDebouncedValue hook (or useMemo/useRef for a debounced fn) keeps it stable. A debounced function created in the render body never debounces.
  4. Min query length — skip 0–1 char queries.
  5. All states — idle / loading / success / empty / error (with retry). Status enum, not a boolean.
  6. 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.

Senior engineer discussion

Seniors pair debounce with AbortController cancellation (explaining debounce alone can't fix races), keep the debounce stable, model status as an enum with all states, and mention a query library for the production version.

Related questions