useThrottle — Rate-limit a value or callback to fire at most once per interval
Recipe
import { useState, useEffect, useRef, useCallback } from "react";
/**
* useThrottledValue
* Returns a throttled copy of `value` that updates at most
* once every `interval` ms.
*/
function useThrottledValue<T>(value: T, interval: number): T {
const [throttled, setThrottled] = useState(value);
const lastUpdated = useRef(Date.now());
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
const now = Date.now();
const elapsed = now - lastUpdated.current;
if (elapsed >= interval) {
setThrottled(value);
lastUpdated.current = now;
} else {
// Schedule a trailing update
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
setThrottled(value);
lastUpdated.current = Date.now();
}, interval - elapsed);
}
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, [value, interval]);
return throttled;
}
/**
* useThrottledCallback
* Returns a stable, throttled version of `callback` that
* executes at most once every `interval` ms.
*/
function useThrottledCallback<T extends (...args: any[]) => void>(
callback: T,
interval: number
): T & { cancel: () => void } {
const callbackRef = useRef(callback);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastCalledRef = useRef(0);
const lastArgsRef = useRef<Parameters<T> | null>(null);
useEffect(() => {
callbackRef.current = callback;
}, [callback]);
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, []);
const cancel = useCallback(() => {
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = null;
lastArgsRef.current = null;
}, []);
const throttled = useCallback(
(...args: Parameters<T>) => {
lastArgsRef.current = args;
const now = Date.now();
const elapsed = now - lastCalledRef.current;
if (elapsed >= interval) {
callbackRef.current(...args);
lastCalledRef.current = now;
} else if (!timerRef.current) {
timerRef.current = setTimeout(() => {
if (lastArgsRef.current) {
callbackRef.current(...lastArgsRef.current);
}
lastCalledRef.current = Date.now();
timerRef.current = null;
lastArgsRef.current = null;
}, interval - elapsed);
}
},
[interval]
) as T & { cancel: () => void };
throttled.cancel = cancel;
return throttled;
}When to reach for this: You need consistent, spaced-out updates during continuous events like scrolling, resizing, or mouse movement, rather than waiting for the event to stop (that would be debounce).
Working Example
"use client";
import { useState, useEffect, useRef } from "react";
function ScrollTracker() {
const [scrollY, setScrollY] = useState(0);
const handleScroll = useThrottledCallback(() => {
setScrollY(window.scrollY);
}, 100);
useEffect(() => {
window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll);
}, [handleScroll]);
return (
<div style={{ position: "fixed", top: 10, right: 10 }}>
Scroll: {scrollY}px
</div>
);
}
function ResizeDisplay() {
const [width, setWidth] = useState(
typeof window !== "undefined" ? window.innerWidth : 0
);
const throttledWidth = useThrottledValue(width, 200);
useEffect(() => {
const handler = () => setWidth(window.innerWidth);
window.addEventListener("resize", handler);
return () => window.removeEventListener("resize", handler);
}, []);
return (
<p>
Raw: {width}px — Throttled: {throttledWidth}px
</p>
);
}What this demonstrates:
useThrottledCallbackfires the scroll handler at most every 100 ms, keeping the UI responsive without flooding state updatesuseThrottledValuesmooths out raw resize values to update at most every 200 ms- Both include a trailing call so the final value is never lost
Deep Dive
How It Works
- Leading call: The first invocation within an interval fires immediately, giving the user instant feedback.
- Trailing call: A timeout captures the latest arguments so the final value during rapid input is never dropped.
- Ref pattern: The latest callback is stored in a ref to avoid stale closures and unnecessary re-renders from dependency changes.
- Cleanup: All timers are cleared on unmount to prevent memory leaks.
Debounce vs Throttle
| Behavior | Debounce | Throttle |
|---|---|---|
| When it fires | After input stops for N ms | At most once every N ms |
| Best for | Search input, form validation | Scroll, resize, drag |
| Responsiveness | Feels delayed | Feels smooth |
| Trailing value | Only the last | Leading + trailing |
Parameters & Return Values
useThrottledValue
| Parameter | Type | Default | Description |
|---|---|---|---|
value | T | — | The value to throttle |
interval | number | — | Minimum ms between updates |
| Returns | T | — | The throttled value |
useThrottledCallback
| Parameter | Type | Default | Description |
|---|---|---|---|
callback | (...args) => void | — | Function to throttle |
interval | number | — | Minimum ms between calls |
| Returns | T & \{ cancel \} | — | Throttled function with cancel |
Variations
Leading-only throttle: Skip the trailing call if you only want the first event in each window. Remove the trailing setTimeout branch.
requestAnimationFrame throttle: For visual updates, replace the timer with requestAnimationFrame for frame-perfect 16 ms throttling:
function useRAFCallback(callback: () => void) {
const rafRef = useRef(0);
const callbackRef = useRef(callback);
callbackRef.current = callback;
return useCallback(() => {
cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(() => callbackRef.current());
}, []);
}TypeScript Notes
- Generic
Ton the value hook preserves the input type. Parameters<T>on the callback hook preserves argument types.- The intersection type
T & \{ cancel \}adds control methods without losing the original signature.
Gotchas
- Missing trailing call — Without the trailing branch, the last value in a burst is lost. Fix: Always include a trailing timer (as shown in the recipe).
- Interval too short — Setting
intervalbelow 16 ms provides no benefit since the browser cannot render faster than one frame. Fix: Use 16 ms minimum, or switch torequestAnimationFrame. - Timer drift —
setTimeoutis not perfectly accurate. For animation-critical code, Fix: userequestAnimationFrameinstead. - SSR crash — Accessing
windowduring server render throws. Fix: Guard withtypeof window !== "undefined"or initialize to a safe default.
Alternatives
| Package | Hook Name | Notes |
|---|---|---|
usehooks-ts | useThrottle | Value-only throttle |
ahooks | useThrottle, useThrottleFn | Full-featured, leading/trailing config |
@uidotdev/usehooks | useThrottle | Minimal value throttle |
lodash | _.throttle | Not a hook; wrap in useRef |
use-debounce | useThrottledCallback | Part of the debounce package |
FAQs
What is the difference between useThrottledValue and useThrottledCallback?
useThrottledValueaccepts a reactive value and returns a throttled copy that updates at most once per interval.useThrottledCallbackaccepts a function and returns a throttled version of that function.- Use the value variant when you already have state changing frequently (e.g., resize width).
- Use the callback variant when you want to throttle an event handler directly (e.g., scroll handler).
Why does the hook store the callback in a ref instead of passing it directly to setTimeout?
Storing the callback in callbackRef ensures the timeout always calls the latest version of the function. Without it, the closure would capture a stale callback from a previous render.
What happens if I set the interval to 0 or a very small number like 5 ms?
- The browser cannot render faster than one frame (~16 ms), so intervals below 16 ms provide no visual benefit.
setTimeoutalso has a minimum delay of ~4 ms in most browsers.- For frame-perfect updates, use
requestAnimationFrameinstead.
How do I cancel a pending throttled callback?
Call the .cancel() method on the returned function:
const throttled = useThrottledCallback(handler, 200);
// Later:
throttled.cancel();Will the last value in a rapid burst be lost?
No. Both hooks include a trailing call via setTimeout. When the burst ends, the trailing timer fires with the most recent value or arguments, ensuring nothing is dropped.
Gotcha: Why does my throttled scroll handler crash during SSR?
- Accessing
windowduring server-side rendering throws a ReferenceError. - Guard with
typeof window !== "undefined"before readingwindow.scrollYor attaching listeners. - The
useEffectin the working example only runs on the client, but initial state must also be safe.
Gotcha: I removed the trailing setTimeout branch and now my UI shows stale data. Why?
Without the trailing call, only the leading invocation fires. If the value changes during the cooldown window, the final update is silently dropped. Always keep the trailing branch to capture the last value.
How does the requestAnimationFrame variation differ from the timer-based throttle?
requestAnimationFramefires once per display frame (~16 ms at 60 Hz).- It automatically adapts to the monitor refresh rate.
- It is ideal for visual updates like animations or canvas redraws.
- The timer-based throttle lets you choose any interval and is better for non-visual work.
How does TypeScript preserve the argument types of the throttled callback?
The generic signature T extends (...args: any[]) => void captures the original function type. Parameters<T> extracts the argument tuple, so the throttled wrapper accepts the same parameters as the original.
What does the intersection type T & \{ cancel: () => void \} do in TypeScript?
It combines the original function type T with an object that has a cancel method. This means the returned function is callable with the same signature as the original, and also exposes .cancel() for cleanup.
When should I use throttle instead of debounce?
- Throttle when you need periodic updates during continuous input (scroll position, drag coordinates, resize).
- Debounce when you only care about the final value after input stops (search queries, form validation).
- Throttle feels responsive; debounce feels delayed.
Related
- useDebounce — delay until input stops
- useWindowSize — a common throttle use case
- useEventListener — declarative event binding