Key Takeaway
Debouncing delays a function until input stops; throttling caps it to at most once per interval. In React, put the timer inside useEffect and always return a cleanup that clears it. That cleanup cancels the pending timer before the next run and on unmount, which is what stops leaked timers, duplicate API calls, and stale closures.
Every React app eventually grows an input that fires too often. A search box that hits your API on every keystroke, a scroll handler that recomputes layout sixty times a second, an autosave that writes on every character. Debouncing and throttling are the two tools for taming that, and both are trivial to get subtly wrong in React because the timer lives across renders while your component is constantly recreating closures around it.
I run a self-hosted stack where a debounced search endpoint sits in front of a PostgreSQL full-text query. The first version leaked timers and hammered the database on every keypress. This is the guide I wish I had then: what the two techniques actually do, why the naive useEffect version leaks, and a small set of reusable hooks that clean up correctly.
What debouncing and throttling actually do
Debouncing waits for a pause. It restarts a timer on every event and only runs the function once the events stop for the full delay. Throttling instead lets the function run immediately, then blocks it until a fixed interval has passed, giving you a steady cadence no matter how fast events arrive. The difference matters: debounce is about the last value after activity settles, throttle is about a predictable sampling rate during activity.
| Aspect | Debounce | Throttle |
|---|---|---|
| Fires when | Activity stops for the full delay | At most once per interval, while active |
| Best for | Search inputs, form validation, autosave | Scroll, resize, mousemove, drag |
| Worst case | If input never pauses, it never fires | Fires on a fixed cadence regardless |
| User feel | Waits for you to finish | Steady, continuous updates |
| Typical delay | 300 to 500 ms | 100 to 250 ms |
The naive useEffect approach and why it leaks
The tempting first attempt is to schedule a setTimeout inside a useEffect keyed on the input value, and stop there. It looks like it works in the demo, then falls apart under real typing. Because there is no cleanup, every keystroke re-runs the effect and starts a brand-new timer without cancelling the previous one. Type ten characters and you schedule ten API calls that all fire.
// The naive version — a new timer on every render, no cleanup
function SearchBox() {
const [query, setQuery] = useState("");
useEffect(() => {
// BUG: this timer is never cleared. Type 10 characters and you
// schedule 10 requests, and every one of them fires.
setTimeout(() => {
fetch(`/api/search?q=${query}`);
}, 500);
}, [query]);
return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}If the component unmounts while a timer is pending, that timer still fires and may call setState on an unmounted component or reference props that no longer exist. Uncleared timers are the classic React memory-leak source: zombie timers that hold their closure alive and do work nobody is waiting for.
A reusable useDebounce hook
The cleanest primitive debounces a value, not a callback. You return the cleanup function from useEffect, and React runs it before the next effect and again on unmount. Each keystroke cancels the previous timer, so only the value that survives the full quiet period ever lands in state. This is the single most important line in the hook, and the one the naive version omits.
import { useEffect, useState } from "react";
export function useDebounce<T>(value: T, delayMs: number): T {
const [debounced, setDebounced] = useState<T>(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delayMs);
// Cleanup runs before the next effect AND on unmount:
// it cancels the pending timer so only the last value survives.
return () => clearTimeout(id);
}, [value, delayMs]);
return debounced;
}Now the consumer stays simple. Track the raw input in state, feed it through useDebounce, and run your effect on the debounced value. Pair the fetch with an AbortController so an in-flight request is cancelled if the debounced value changes again before it resolves, which prevents out-of-order responses overwriting fresh results.
function SearchBox() {
const [query, setQuery] = useState("");
const [results, setResults] = useState([]);
const debouncedQuery = useDebounce(query, 400);
useEffect(() => {
if (!debouncedQuery) return;
const controller = new AbortController();
fetch(`/api/search?q=${debouncedQuery}`, { signal: controller.signal })
.then((r) => r.json())
.then(setResults)
.catch(() => {}); // ignore aborts
return () => controller.abort();
}, [debouncedQuery]);
return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}Debouncing a callback without stale closures
Sometimes you need to debounce an action, not derive a value: a save button, an analytics event, a resize-driven recompute. The trap here is the stale closure. If you capture the callback once, the debounced function keeps calling an old version that sees old props and state. The fix is to store the latest callback in a ref and update it on every render, so the timer always calls the current function while the debounced identity stays stable.
import { useCallback, useEffect, useRef } from "react";
export function useDebouncedCallback<A extends unknown[]>(
callback: (...args: A) => void,
delayMs: number,
) {
const timer = useRef<ReturnType<typeof setTimeout>>();
// Keep the latest callback in a ref so the debounced function
// always sees fresh props/state — no stale closure.
const latest = useRef(callback);
useEffect(() => {
latest.current = callback;
});
const debounced = useCallback(
(...args: A) => {
clearTimeout(timer.current);
timer.current = setTimeout(() => latest.current(...args), delayMs);
},
[delayMs],
);
// Cancel any pending call when the component unmounts.
useEffect(() => () => clearTimeout(timer.current), []);
return debounced;
}The ref-plus-effect pattern is exactly the problem React 19.2 solved with useEffectEvent: a stable function identity that always reads the latest values without going into the dependency array. If you are on 19.2 or later, you can lean on it instead of hand-rolling the latest-callback ref for the non-reactive part of the logic.
Throttling scroll and resize handlers
For high-frequency events, debounce is the wrong tool: a scroll handler that only fires after you stop scrolling feels broken. You want throttle, running at most once per interval. A timestamp-based throttle is the simplest correct form. And crucially, whatever you attach with addEventListener must be removed in the same effect cleanup, or you leak a listener on every mount.
import { useCallback, useEffect, useRef } from "react";
export function useThrottledCallback<A extends unknown[]>(
callback: (...args: A) => void,
intervalMs: number,
) {
const lastRun = useRef(0);
const latest = useRef(callback);
useEffect(() => {
latest.current = callback;
});
return useCallback(
(...args: A) => {
const now = Date.now();
if (now - lastRun.current >= intervalMs) {
lastRun.current = now;
latest.current(...args);
}
},
[intervalMs],
);
}
// Usage: throttle a scroll handler to at most once per 200ms
function Header() {
const onScroll = useThrottledCallback(() => {
// read scrollY, toggle a shadow, etc.
}, 200);
useEffect(() => {
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, [onScroll]);
return null;
}Cleanup on unmount is the whole game
Whether you debounce a value, debounce a callback, or throttle an event, the same discipline applies. Every timer, listener, or subscription you start must be torn down in a cleanup function. React fires that cleanup before re-running the effect and one final time when the component unmounts, which covers both the re-render case and the teardown case.
- Return clearTimeout or clearInterval from every effect that creates one.
- Remove every event listener you add, using the same function reference.
- Abort in-flight fetches with AbortController so late responses cannot overwrite fresh state.
- Store the callback in a ref, or use useEffectEvent, so the debounced function never captures stale props or state.
- For anything beyond a few call sites, reach for a battle-tested library rather than reimplementing edge cases.
You do not always have to write these yourself. The use-debounce package on npm gives you useDebounce and useDebouncedCallback with cancel, flush, and maxWait already handled, and Lodash debounce and throttle remain the reference implementations for the leading and trailing edge options most hand-rolled versions get wrong.
Reach for the custom hooks when you want zero dependencies and full control, and for the library when you need the tricky options. Either way, the mental model is the same: the timer outlives a render, so a render must never leave one behind.

