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.
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:
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
const debouncedSearch = useMemo(
() => debounce((q) => fetchResults(q), 300),
[] // create ONCE — never per render
);
useEffect(() => () => debouncedSearch.cancel(), [debouncedSearch]); // cleanupThe 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.