Search across all documentation pages
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
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).
"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:
useThrottledCallback fires the scroll handler at most every 100 ms, keeping the UI responsive without flooding state updatesuseThrottledValue smooths out raw resize values to update at most every 200 ms| 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 |
| Parameter | Type | Default | Description |
|---|---|---|---|
value | T | - | The value to throttle |
interval | number | - | Minimum ms between updates |
| Returns | T | - | The throttled value |
| Parameter | Type | Default | Description |
|---|---|---|---|
callback | (...args) => void | - | Function to throttle |
interval | number | - | Minimum ms between calls |
| Returns | T & \{ cancel \} | - | Throttled function with cancel |
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());
}, []);
}T on the value hook preserves the input type.Parameters<T> on the callback hook preserves argument types.T & \{ cancel \} adds control methods without losing the original signature.interval below 16 ms provides no benefit since the browser cannot render faster than one frame. Fix: Use 16 ms minimum, or switch to requestAnimationFrame.setTimeout is not perfectly accurate. For animation-critical code, Fix: use requestAnimationFrame instead.window during server render throws. Fix: Guard with typeof window !== "undefined" or initialize to a safe default.| 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 |
useThrottledValue accepts a reactive value and returns a throttled copy that updates at most once per interval.useThrottledCallback accepts a function and returns a throttled version of that function.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.
setTimeout also has a minimum delay of ~4 ms in most browsers.requestAnimationFrame instead.Call the .cancel() method on the returned function:
const throttled = useThrottledCallback(handler, 200);
// Later:
throttled.cancel();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.
window during server-side rendering throws a ReferenceError.typeof window !== "undefined" before reading window.scrollY or attaching listeners.useEffect in the working example only runs on the client, but initial state must also be safe.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.
requestAnimationFrame fires once per display frame (~16 ms at 60 Hz).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.
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.
Reviewed by Chris St. John·Last updated Jul 16, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥