useThrottle Hook
Throttle a changing value in React: at most one update per interval, trailing flush, cleanup, and how this differs from useDebouncedValue.
- react
- hooks
- throttle
- interview
A scroll position, a drag coordinate, or a live window width can change on every frame. If you put that raw value in state, you re-render faster than paint. If you copy useDebouncedValue and rename it, you wait for quiet. The scrollbar then jumps to the last sample after the user already stopped. That is the wrong contract.
useThrottledValue caps the rate: at most one commit per interval while the source keeps changing, then a trailing flush so the last sample is not dropped. Debounce waits for a pause. Throttle samples a busy stream. Resize and scroll usually throttle. Search usually debounces.
The vanilla helper in Throttle Implementation wraps a function. The React hook wraps a value, so other effects can depend on the throttled data. Same split as useDebouncedValue. Cleanup still matters: a trailing timer must not call setState after unmount, and it must not be reset on every change the way debounce resets.
- Throttle: at most one update per interval while values keep arriving. Debounce: wait until values stop arriving.
- Keep a
lastFiredtimestamp. If the interval has elapsed, commit now (leading). Else schedule one trailing timeout for the remaining time.- Store the latest source in a ref so the trailing flush does not freeze the first sample in the window.
- Do not
clearTimeouton every value change. That reset is debounce. Clear on unmount (and whenintervalchanges).- First paint matches the source. There is no initial wait. Seed with
useState(value).
A trailing value that updates at most once per interval
Two APIs get conflated. Name which one you are writing.
| Hook | Input | Output | Typical use |
|---|---|---|---|
useThrottledValue(value, ms) |
Changing data | Data that updates on a cadence | Scroll Y, pointer position, innerWidth |
useThrottle(fn, ms) |
Callback | Stable wrapped function | Native scroll listener, canvas pointermove |
For a controlled input you still keep immediate state for the field. Throttle the derived value you chart, persist, or send. Throttling the onChange of a text input makes typing feel like a remote desktop.
Timestamp plus one trailing timer
import { useEffect, useRef, useState } from 'react';
function useThrottledValue(value, interval) {
const [throttled, setThrottled] = useState(value);
const lastFiredRef = useRef(0);
const timeoutRef = useRef(null);
const valueRef = useRef(value);
valueRef.current = value;
useEffect(() => {
if (interval == null || interval < 0) {
setThrottled(value);
return undefined;
}
const now = Date.now();
const remaining = interval - (now - lastFiredRef.current);
function flush() {
if (timeoutRef.current != null) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
lastFiredRef.current = Date.now();
setThrottled(valueRef.current);
}
if (remaining <= 0) {
flush();
return undefined;
}
if (timeoutRef.current == null) {
timeoutRef.current = setTimeout(flush, remaining);
}
return undefined;
}, [value, interval]);
useEffect(() => {
return () => {
if (timeoutRef.current != null) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
};
}, [interval]);
return throttled;
}
Procedure for a burst of values with interval = 100:
- Mount with
value = 0.useState(0)already matches.lastFiredis0, soremainingis negative. The effect flushes immediately (React bails out if state isObject.is-equal).lastFiredbecomes now. - At t=10, 20, 30 the source is 10, 20, 30. Each render writes
valueRef.remainingis still positive. The first of those schedulessetTimeout(flush, remaining). Later ones do not schedule a second timer. - When the timer fires,
flushreadsvalueRef.current(30, not 10) and commits once. - A value that arrives after the window opens (
remaining <= 0) flushes on the leading edge again. - Unmount or
intervalchange: the second effect clears a pending trailing timer.
flush also clears a leftover trailing id. Without that, a leading commit at the window boundary can run twice: once from remaining <= 0, once from the timeout that was already queued.
That “latest value in a ref” line is the difference between a correct throttle and a buggy one that paints the first coordinate of the drag for the whole window.
Why copying debounce is wrong
// Broken throttle: this is debounce with extra variables
useEffect(() => {
const id = setTimeout(() => setThrottled(value), interval);
return () => clearTimeout(id);
}, [value, interval]);
Cleanup on every value change resets the wait. You only commit after quiet. Mid-scroll UI never moves.
A slightly less broken cousin still uses the full interval as the timeout delay and clears it on each change. The flush then slides forward with the last event, which is debounce from the tail of the burst. Trailing throttle fires at lastFired + interval, not at lastEvent + interval.
| Behavior | Debounce (useDebouncedValue) |
Throttle (useThrottledValue) |
|---|---|---|
| While events continue | Timer resets. No commit. | Commits on cadence. |
| When events stop | One commit after delay of quiet. |
Trailing commit at the end of the current window. |
| Cleanup on value change | Required (clearTimeout). |
Harmful if it kills the pending trailing flush. |
| Search query | Yes | No |
| Scroll / resize / drag | Rarely | Yes |
performance.now() is monotonic and slightly nicer than Date.now() if the system clock jumps. Interviews accept Date.now().
Handler throttle vs value throttle
If the source is a native listener, wrap the function with the vanilla helper and keep it in a ref so you do not re-create the throttled function every render (same trap as recreating debounce() in the debounce post). For paint-aligned work, requestAnimationFrame is often the better “interval” than 16. The window-size hook in this cluster uses rAF for that reason.
function ScrollProgress() {
const [y, setY] = useState(0);
const throttledY = useThrottledValue(y, 100);
useEffect(() => {
function onScroll() {
setY(window.scrollY);
}
window.addEventListener('scroll', onScroll, { passive: true });
return () => window.removeEventListener('scroll', onScroll);
}, []);
return <progress value={throttledY} max={2000} />;
}
Here the raw y still updates every scroll event (cheap number). Downstream work that is expensive should read throttledY. If even setY is too hot, throttle the handler instead and skip the extra state.
Strict Mode will run the subscribe effect twice in development. The trailing timer from the first mount is cleared by the unmount cleanup. Production does not double-invoke.
Footguns
Clearing the trailing timer in the [value] effect. You meant debounce. If you must return a cleanup from that effect, only clear when interval changes or the component unmounts, not because value ticked.
Stale trailing payload. Capturing value in the setTimeout closure without a ref freezes the first sample in the window. Always flush valueRef.current.
Object identities. Throttling { x, y } when the parent passes a new object every render never goes quiet and never Object.is-equals. Throttle primitives, or memoize the source.
interval of 0. setTimeout(fn, 0) is still a macrotask, not “next line,” and nested timers get clamped. For frame-rate work use rAF.
SSR. setTimeout does not run on the server. Server HTML and the client’s first paint both see the raw value. That is what you want. Do not invent a throttled placeholder.
Writing valueRef during render. It is a “latest value” box so a timer can beat the next effect. Do not use that pattern for previous-value tracking. usePrevious writes after commit on purpose.
Interview out-loud answer
Debounce waits for quiet. Throttle caps the rate while the stream is busy, then flushes the last sample so you do not drop the end of a scroll. I keep
lastFiredin a ref, commit immediately if the interval has elapsed, and otherwise schedule a single trailingsetTimeoutfor the remaining time. The timeout reads the latest value from a ref, not the closure from the first event in the window. I do not clear that timer on every value change. I do clear it on unmount. The vanillathrottlehelper is for handlers. The hook is for data that other effects can depend on.
Further reading
- MDN:
setTimeout - MDN:
performance.now() - react.dev: Synchronizing with Effects
- react.dev: Reusing Logic with Custom Hooks
Related on this site
- Throttle Implementation: leading, trailing,
cancel - useDebouncedValue Hook: wait for quiet, not a cadence
- Debounce Implementation: the other timer contract
- useEffect Fundamentals: cleanup is part of the effect
- The Event Loop: timers are macrotasks
- requestAnimationFrame: paint-aligned throttle
- Implement a Fake setTimeout: test the window without a real clock
Related guides
- useCopyToClipboard HookCopy text with navigator.clipboard, fallback execCommand, transient copied state, and permissions that fail in insecure contexts.
- useDebouncedValue HookDebounce a changing value in React without firing effects on every keystroke. Cleanup, leading vs trailing, and the stale timer that updates unmounted state.
- useEventListener HookAttach a window, document, or element listener with a stable handler ref so you do not rebind on every render. Capture option and target changes.
- useIdle HookDetect idle users: reset a timer on input events, fire idle after timeout, and decide whether mount starts the timer immediately.
- useInfiniteScroll HookLoad the next page when a sentinel enters the viewport. IntersectionObserver, hasMore, in-flight guards, and the footer that never intersects.