Debouncing and Throttling in React
Two techniques for taming events that fire too often — search inputs, scroll handlers, resize listeners. When to use which, and how to build both as reusable hooks.
Debouncing and Throttling in React
Some events fire far more often than you can usefully respond to them. A user typing in a search box fires onChange on every keystroke. A scroll handler fires dozens of times a second. A window resize listener fires continuously while the user drags. If each event triggers an API call or an expensive recalculation, you've built a performance problem.
Debouncing and throttling are the two standard fixes. They sound similar and people mix them up constantly — so let's nail the distinction first, then build both.
The core difference
Both limit how often a function runs. They differ in which calls get through.
- Debounce: wait until the events stop, then run once. "Do this when the user is done."
- Throttle: run at most once per interval, no matter how many events fire. "Do this at a steady rate while it's happening."
A concrete picture. Imagine the function logs each time it actually runs:
Events: x x x x x x x x x x x x (rapid burst, then stop)
Debounce: . . . . . . . . . . . . R (one run, after it goes quiet)
Throttle: R . . . R . . . R . . . R (runs on a fixed cadence)
Debounce is right when you only care about the final state — search-as-you-type, autosave, validating a field after the user stops typing. Throttle is right when you want regular updates during a continuous action — scroll position, drag handlers, resize.
Debounce as a hook
The cleanest pattern in React is to debounce the value, not the callback. You hold a fast-updating state and derive a slow-updating "debounced" version of it.
import { useEffect, useState } from "react";
export function useDebouncedValue<T>(value: T, delayMs: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delayMs);
// Cleanup cancels the pending timeout if `value` changes
// before the delay elapses — this is what makes it debounce.
return () => clearTimeout(id);
}, [value, delayMs]);
return debounced;
}
The whole trick is the cleanup. Every time value changes, the previous setTimeout is cancelled and a new one starts. The setter only fires when value holds still for the full delay.
Using it for a search box:
function Search() {
const [query, setQuery] = useState("");
const debouncedQuery = useDebouncedValue(query, 400);
useEffect(() => {
if (!debouncedQuery) return;
// Fires at most once per 400ms-of-quiet, not on every keystroke
fetch(`/api/search?q=${encodeURIComponent(debouncedQuery)}`)
.then((r) => r.json())
.then(setResults);
}, [debouncedQuery]);
return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}
The input stays perfectly responsive (it's bound to the fast query), but the network call only fires once the user pauses. That's the behavior you almost always want for search.
Throttle as a hook
Throttling is most natural around a callback, because you usually want to react to a stream of events at a steady rate. Here's a hook that returns a throttled version of a function:
import { useRef, useCallback } from "react";
export function useThrottledCallback<A extends unknown[]>(
fn: (...args: A) => void,
intervalMs: number
): (...args: A) => void {
const lastRun = useRef(0);
const fnRef = useRef(fn);
fnRef.current = fn; // always call the latest fn
return useCallback(
(...args: A) => {
const now = Date.now();
if (now - lastRun.current >= intervalMs) {
lastRun.current = now;
fnRef.current(...args);
}
},
[intervalMs]
);
}
Storing fn in a ref means the throttled wrapper is stable (it never changes identity) but always calls the current closure — so you avoid stale-state bugs without re-creating the throttler on every render.
Using it on a scroll handler:
function ScrollProgress() {
const [progress, setProgress] = useState(0);
const onScroll = useThrottledCallback(() => {
const scrolled = window.scrollY;
const max = document.body.scrollHeight - window.innerHeight;
setProgress(max > 0 ? (scrolled / max) * 100 : 0);
}, 100); // update at most 10x/second
useEffect(() => {
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, [onScroll]);
return <div style={{ width: `${progress}%` }} className="progress-bar" />;
}
Without the throttle, setProgress would run on every scroll tick — potentially hundreds of times a second, each one a render. At 100ms you get smooth-enough updates at a fraction of the cost.
Choosing the delay
The delay is a UX knob, not a magic constant.
- Search debounce: 300–500ms. Long enough to skip intermediate keystrokes, short enough that results feel immediate when you stop.
- Autosave debounce: 1000–2000ms. You don't want to save mid-sentence.
- Scroll/resize throttle: 16–100ms. 16ms ≈ one frame (60fps) for animation-tier smoothness; 100ms is fine for things like a progress bar or lazy-loading trigger.
Too long and the app feels laggy; too short and you haven't really solved the problem. Start with these and tune by feel.
Common mistakes
Debouncing when you meant to throttle (or vice versa). A scroll progress bar that only updates after you stop scrolling (debounce) feels broken. A search that fires on every keystroke up to a rate cap (throttle) still hammers your API. Match the technique to the intent: "when it stops" vs "at a steady rate."
Re-creating the debounced function every render. If you define the timer logic inline in the component body without useRef/useCallback, each render makes a fresh closure and the previous timer is orphaned — the debounce silently stops working. The value-based hook above sidesteps this entirely, which is why it's the pattern I reach for first.
Forgetting cleanup. Always cancel pending timers and remove event listeners on unmount, or you'll set state on an unmounted component and leak handlers.
When to reach for a library
The hooks above are ~15 lines each and cover the vast majority of cases. Reach for a battle-tested utility only when you need the trickier options: leading and trailing invocation, a maxWait (debounce that's forced to fire after some ceiling even if events never stop), or cancel/flush controls. Those edge behaviors are fiddly to get right by hand. For everything else, the hooks here are all you need — paste them in and move on.
Stay in the flow
Get vibecoding tips, new tool announcements, and guides delivered to your inbox.
No spam, unsubscribe anytime.