Back to React
React
medium
mid

How would you debounce user input to avoid unnecessary re renders or API calls?

Keep the input controlled and responsive; debounce the *derived effect* (API call / expensive filter), not the keystrokes. In React: a debounced value via useEffect + timeout, or a stable debounced callback via useMemo/useRef — never re-create the debounced fn each render.

5 min read·~12 min to think through

The key insight: debounce the consequence, not the typing. The input itself must stay instant; only the expensive downstream work (API call, filter) gets debounced.

Approach 1: a useDebouncedValue hook

The input stays fully controlled and responsive; a separate debounced value lags behind it and drives the effect:

jsx
function useDebouncedValue(value, delay = 300) {
  const [debounced, setDebounced] = useState(value);
  useEffect(() => {
    const id = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(id); // cancel on every new keystroke
  }, [value, delay]);
  return debounced;
}

function Search() {
  const [query, setQuery] = useState("");          // instant, controlled
  const debouncedQuery = useDebouncedValue(query, 300);

  useEffect(() => {
    if (!debouncedQuery) return;
    const controller = new AbortController();
    fetchResults(debouncedQuery, { signal: controller.signal });
    return () => controller.abort();               // cancel stale request
  }, [debouncedQuery]);

  return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}

The clearTimeout cleanup is what makes it work — every keystroke cancels the pending update and reschedules.

Approach 2: a stable debounced callback

jsx
const debouncedSearch = useMemo(
  () => debounce((q) => fetchResults(q), 300),
  [] // create ONCE — never per render
);
useEffect(() => () => debouncedSearch.cancel(), [debouncedSearch]); // cleanup

The mistakes this question is checking for

1. Re-creating the debounced function every render. const debounced = debounce(fn, 300) inside the component body makes a brand-new debounced function on every render — each with its own fresh timer — so it never actually debounces. Must wrap in useMemo/useRef (or define outside the component).

2. Debouncing the input value itself. Don't delay setQuery — that makes the input feel laggy/janky. The input is controlled and immediate; the value used for the API is the debounced one.

3. No cleanup. Clear the timeout / call .cancel() on unmount, or you set state on an unmounted component.

4. Not cancelling the request. Debounce reduces frequency; AbortController kills out-of-order responses.

The framing

"I keep the input controlled and instant — never debounce setState for the field itself, that makes typing feel laggy. I debounce the derived value or callback: a useDebouncedValue hook where a setTimeout with a clearTimeout cleanup lags a second value behind the input, and that value drives the fetch effect. The classic bug is re-creating the debounced function every render so it never debounces — it has to be stabilized with useMemo/useRef. And I pair it with an AbortController to cancel stale requests and cleanup on unmount."

Follow-up questions

  • Why does re-creating the debounced function every render break it?
  • Why debounce the derived value instead of the input's onChange?
  • How does the useEffect cleanup implement the debounce?
  • Why pair debouncing with AbortController?

Common mistakes

  • Creating the debounced function inside the render body — never debounces.
  • Debouncing setState for the input itself, making typing feel laggy.
  • No cleanup — setting state after unmount, or leaked timers.
  • Debouncing but not cancelling stale requests — out-of-order responses.

Performance considerations

  • Debouncing cuts API calls and expensive re-renders/filters triggered by the value. The input itself stays at full responsiveness because only the derived value is delayed. Stabilizing the debounced fn avoids leaking timers each render.

Edge cases

  • Component unmounts with a pending debounced call.
  • User hits Enter before the debounce fires — handle explicit submit.
  • Delay too long feels unresponsive; too short defeats the purpose (~250–400ms typical).
  • Empty query after clearing input — skip the call.

Real-world examples

  • Search-as-you-type and autocomplete inputs.
  • Debounced autosave on a form or editor.

Senior engineer discussion

Seniors separate the responsive controlled input from the debounced derived value, stabilize the debounced function correctly, add cleanup, and pair it with request cancellation — and can explain why the naive in-render debounce silently does nothing.

Related questions