React useThrottle Hook
Throttling caps the rate at which a value updates while still letting changes through at a steady cadence. This snippet covers the basic useThrottle that emits at most once per window, a useThrottledCallback variant for wrapping functions, and the leading-plus-trailing edge form that keeps the first and last events without dropping the tail. Use it for scroll handlers, mouse trackers, and any high-frequency stream you need to sample.
927 views
5
function useThrottle(value, interval = 200) {
const [throttled, setThrottled] = useState(value);
const lastUpdateRef = useRef(Date.now());
useEffect(() => {
const elapsed = Date.now() - lastUpdateRef.current;
if (elapsed >= interval) {
lastUpdateRef.current = Date.now();
setThrottled(value);
return;
}
const id = setTimeout(() => {
lastUpdateRef.current = Date.now();
setThrottled(value);
}, interval - elapsed);
return () => clearTimeout(id);
}, [value, interval]);
return throttled;
}
function useState(v) { return [v, () => {}]; }
function useEffect(fn) { fn(); }
function useRef(v) { return { current: v }; }
console.log('throttled:', useThrottle(42, 100));The hook tracks the last update time in a ref and only forwards the current value when the elapsed gap is long enough. If the value changed too soon, a setTimeout schedules the next emission for the remaining time so the tail of a fast burst is not lost. This is the difference from a debounce: throttle still emits at a steady cadence during sustained input, while debounce only emits after activity stops. Use it for mouse-move handlers, scroll positions, or any sensor stream where intermediate samples are useful.
function useThrottledCallback(fn, interval = 200) {
const fnRef = useRef(fn);
fnRef.current = fn;
const lastFireRef = useRef(0);
return (...args) => {
const now = Date.now();
if (now - lastFireRef.current >= interval) {
lastFireRef.current = now;
fnRef.current(...args);
}
};
}
function useRef(v) { return { current: v }; }
const log = useThrottledCallback((x) => console.log('emit', x), 50);
log('a');
log('b');
setTimeout(() => log('c'), 60);
setTimeout(() => console.log('done'), 120);The callback variant wraps a function so calls within interval ms are simply dropped instead of queued. Storing fn in a ref keeps the closure fresh while the returned wrapper stays referentially stable across renders, which avoids re-attaching event listeners every render. This is the correct shape for high-frequency event handlers like onScroll, onMouseMove, or onResize where dropping intermediate calls is fine. If you need the trailing edge as well (so the very last call still lands), see the next accordion.
function useThrottleLT(value, interval = 200) {
const [out, setOut] = useState(value);
const lastFireRef = useRef(0);
const trailingRef = useRef(null);
useEffect(() => {
const now = Date.now();
const remaining = interval - (now - lastFireRef.current);
if (remaining <= 0) {
lastFireRef.current = now;
setOut(value);
return;
}
if (trailingRef.current) clearTimeout(trailingRef.current);
trailingRef.current = setTimeout(() => {
lastFireRef.current = Date.now();
setOut(value);
}, remaining);
return () => trailingRef.current && clearTimeout(trailingRef.current);
}, [value, interval]);
return out;
}
function useState(v) { return [v, () => {}]; }
function useEffect(fn) { fn(); }
function useRef(v) { return { current: v }; }
console.log('lt-throttled:', useThrottleLT('start', 100));Pure leading-edge throttle drops every event after the first within a window, which means the final value of a burst never reaches subscribers. Pure trailing-edge throttle has the opposite problem: the first event waits a full interval. The leading-plus-trailing form fires immediately on the first event, then schedules a trailing emission so the last value of any burst still arrives. This is the shape you want for scroll-position trackers and live previews where both responsiveness and consistency matter.
