Back to Machine Coding
Machine Coding
easy
mid

How would you build a debounced search box in React?

Controlled input + debounced effect: store the raw input in state, run a debounced effect that fires the search N ms after typing stops. Cancel in-flight requests on new input (AbortController), handle race conditions (ignore stale responses), show loading/empty/error states, and make the listbox accessible.

4 min read·~30 min to think through

A debounced search box is the canonical "you understand effects, timing, and race conditions" challenge.

1. Controlled input + debounced effect

jsx
function SearchBox() {
  const [query, setQuery] = useState("");
  const [results, setResults] = useState([]);
  const [status, setStatus] = useState("idle");      // idle | loading | error

  useEffect(() => {
    if (!query.trim()) { setResults([]); setStatus("idle"); return; }
    const controller = new AbortController();
    const t = setTimeout(async () => {
      setStatus("loading");
      try {
        const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`, { signal: controller.signal });
        const data = await res.json();
        setResults(data);
        setStatus("idle");
      } catch (e) {
        if (e.name !== "AbortError") setStatus("error");
      }
    }, 300);

    return () => { clearTimeout(t); controller.abort(); };
  }, [query]);

  return (
    <div>
      <input value={query} onChange={e => setQuery(e.target.value)} aria-label="Search" />
      {status === "loading" && <Spinner />}
      {status === "error" && <p role="alert">Something went wrong.</p>}
      <ul role="listbox">{results.map(r => <li key={r.id} role="option">{r.name}</li>)}</ul>
    </div>
  );
}

2. Why debounce in the effect, not the handler

Debouncing in the handler with useCallback(debounce(...)) is fragile — refs get stale, the deps array gets weird. Putting the timer inside the effect with the query in deps is the cleanest pattern: each keystroke schedules a new run; the cleanup cancels the previous one. Same with the AbortController — each effect owns its own request and cancels it on cleanup.

3. Race conditions

Even with debouncing, a slow response from query "ja" might land after the response for "java". AbortController is the right fix — old requests are cancelled before they resolve. A backup pattern is a "latest query" ref and ignore-if-stale check.

4. UX details

  • Min length before firing (often 2 chars).
  • Trim before checking.
  • Empty state — "Type to search."
  • No results — distinguish from idle and loading.
  • Error state with retry.
  • Clear button that resets query and focuses the input.

5. Accessibility — combobox pattern

For an autocomplete dropdown:

  • role="combobox" with aria-expanded, aria-controls, aria-autocomplete="list".
  • Listbox of options, aria-activedescendant for current highlight.
  • Keyboard: ArrowDown / ArrowUp to navigate, Enter to select, Escape to close.
  • Announce result count via a polite live region.

6. The polish

  • Cache previous queries (Map<query, results>) or use React Query with the query as the key — instant on revisit.
  • Show recent / popular searches when empty.
  • Highlight matching substrings in results.

Interview framing

"Controlled input feeding a debounced effect: the input updates state on every keystroke, an effect on [query] schedules a setTimeout for ~300ms; cleanup clears the timer and aborts the in-flight request. The AbortController kills the race condition where a slow earlier response would overwrite a newer one. Then UX states: idle, loading, empty, error — and a combobox ARIA shell if it's a dropdown autocomplete."

Follow-up questions

  • Why debounce inside the effect instead of wrapping the handler?
  • How does AbortController prevent race conditions?
  • What ARIA roles does an autocomplete need?
  • How would you cache previous queries?

Common mistakes

  • No abort — slow earlier response overwrites a newer one.
  • Debouncing in the handler with stale closures over state.
  • No min-length / trim — firing on empty.
  • Missing loading/empty/error states.
  • No keyboard navigation for the result list.

Performance considerations

  • Debounce caps request rate (~3/sec at 300ms). Aborts free server resources too. Caching turns revisits into 0-latency.

Edge cases

  • Very fast typing followed by stop — make sure the final query fires.
  • Network error mid-typing.
  • Backend echoing a different query than requested (verify or trust).
  • Unmount during a pending fetch — abort.

Real-world examples

  • Google / Algolia search-as-you-type.
  • Slack channel/user search.
  • Linear command palette.

Senior engineer discussion

Seniors handle the race condition deliberately with AbortController, debounce in the effect (not the handler) for a clean cleanup, and add the combobox ARIA shell — not as polish, as table stakes.

Related questions