Build a React hook that returns a throttled copy of a value. As the source value changes — on every keystroke, scroll event, or window resize — the throttled copy updates at most once per interval milliseconds, so anything reading it (an expensive search, a chart redraw) runs on a steady cadence instead of on every change. Throttle is the close cousin of debounce, but they answer different questions: debounce waits for the source to go quiet before committing, while throttle commits on a fixed clock no matter how busy the source is. The catch most people miss: the very last value of a burst must still land, even though it arrived before the interval elapsed.
function useThrottle<T>(value: T, interval: number): T;
The hook returns the throttled value. The first render returns value as-is.
function Search({ query }) {
// `query` may change on every keystroke; `throttled` changes at most
// once every 200ms, so we only re-run the expensive search on a cadence.
const throttled = useThrottle(query, 200);
const results = useMemo(() => expensiveSearch(throttled), [throttled]);
return <ResultList items={results} />;
}
// interval = 200, source changes at t = 0, 50, 90, 130 with values a,b,c,d
// t=0: throttled === 'a' (first value lands immediately)
// t=50: throttled === 'a' (inside the interval — held)
// t=90: throttled === 'a' (still inside — held)
// t=130: throttled === 'a' (still inside — held)
// t=200: throttled === 'd' (interval elapsed — the LATEST value lands)
You'll build a hook that mirrors a value but only lets it change on a fixed clock — at most once per interval — while making sure the very last value of a fast burst still lands.
A value in your component changes far too often to act on directly: a search box fires on every keystroke, a scroll handler fires dozens of times a second. You want a copy of that value that updates on a steady beat — say, once every 200ms — so the expensive work hanging off it runs on a cadence instead of on every change. That's throttle. It's the cousin of debounce, but they're not the same: debounce waits until the source goes quiet and then commits; throttle commits on a fixed clock regardless of how busy the source is. The part that trips people up is the trailing edge — when a burst ends mid-interval, the final value arrived before the clock ticked, and a careless throttle drops it.
Picture two clocks. The source changes whenever it likes. Throttle keeps its own metronome: it lets the first value through immediately, then refuses to commit again until interval ms have passed since the last commit. When a change arrives too early, throttle doesn't discard it — it remembers it as "the latest" and sets a single trailing timer for the time remaining in the window. When that timer fires, it commits whatever the latest value is by then. So the throttled copy moves on the clock, and the last value of any burst is guaranteed to land within roughly one interval.
The obvious version keeps a timestamp of the last commit and, on each render, commits only if enough time has already passed:
const { useState, useRef } = require('react');
function useThrottle(value, interval) {
const [throttled, setThrottled] = useState(value);
const lastRun = useRef(Date.now());
// If a full interval has already elapsed, commit the new value now.
if (Date.now() - lastRun.current >= interval) {
lastRun.current = Date.now();
if (value !== throttled) setThrottled(value);
}
return throttled;
}
This handles the leading edge fine — the first change after a quiet stretch lands right away — but it silently drops the trailing edge. Consider a burst 1, 2, 3 that all arrives inside one interval. Each render checks elapsed >= interval, finds it false, and does nothing. There's no timer scheduled to revisit the decision later, so when the burst stops, 3 is never committed. The throttled value is frozen at its old value until the source happens to change again — which, for the end of a burst, may be never. The fix has to schedule a commit for the time remaining, not just check on the way past.
const { useState, useRef, useEffect } = require('react');
function useThrottle(value, interval) {
const [throttled, setThrottled] = useState(value);
// When we last committed. Starts "now" so the first interval is measured
// from mount. A ref, so updating it never triggers a render.
const lastRun = useRef(Date.now());
// Always holds the freshest value. The trailing timer reads this at fire
// time, so it commits the LATEST value even if more changes arrived after
// it was scheduled.
const latest = useRef(value);
latest.current = value;
useEffect(() => {
const elapsed = Date.now() - lastRun.current;
if (elapsed >= interval) {
// The window is already open — commit immediately (leading edge).
lastRun.current = Date.now();
setThrottled(latest.current);
return undefined;
}
// Too soon. Schedule ONE trailing timer for the time left in the window.
// When it fires it commits latest.current — the freshest value by then.
const id = setTimeout(() => {
lastRun.current = Date.now();
setThrottled(latest.current);
}, interval - elapsed);
// Clear the pending timer if the value changes again or we unmount, so we
// never stack timers and never setState into a dead component.
return () => clearTimeout(id);
}, [value, interval]);
return throttled;
}
module.exports = { useThrottle };
The key shift from the naive version is the trailing setTimeout. Instead of only acting when the window is already open, the effect schedules a commit for exactly the time remaining when the window is still closed. Because each run's cleanup clears the prior timer, a fast burst replaces its own pending timer over and over — so only one commit lands per window, and it commits latest.current, the freshest value at fire time. The latest ref is what lets a timer scheduled at value 1 still commit value 3.
Every time value changes, the effect re-runs and makes the same small decision: is the window open or not?
Take useThrottle(value, 200) where the source goes 0 → 1 at t=0, → 2 at t=50, → 3 at t=90, then stops:
0. useState(0) returns 0 immediately, so the first read is 0. lastRun is set to t=0. The effect runs: elapsed is ~0, which is < 200, so it schedules a trailing timer for ~200ms and the throttled value stays 0.1. The effect's cleanup clears the t=0 timer. latest.current is now 1. elapsed (~50) is still < 200, so it schedules a fresh trailing timer for the ~150ms remaining.2, then quickly 3. Same story: each change clears the previous timer and schedules a new one. latest.current ends up 3. No commit has happened yet — the throttled value is still 0, exactly as throttle promises inside a window.lastRun to now and calls setThrottled(latest.current) — which is 3. The throttled value becomes 3. The burst's final value landed within one interval, and only one commit happened for the whole burst.clearTimeout, so no setThrottled is called into the unmounted component.elapsed >= interval already holds drops the last value of any burst — type 1, 2, 3 fast and the throttled copy never reaches 3. Fix: schedule a setTimeout for interval - elapsed that commits latest.current.value inside the timer instead of a ref. A timer scheduled at value 1 that closes over value will commit 1, even though 3 arrived later. Fix: read latest.current at fire time so the commit always uses the freshest value.clearTimeout in cleanup. Without it, a fast burst stacks one pending timer per change — several fire in a row, and timers fire after unmount and call setState on a dead component. Fix: return () => clearTimeout(id) from the effect.throttle) take { leading, trailing } flags so you can suppress the immediate first commit or the final trailing one. The hook above is leading-and-trailing; gating each behind a flag is a small extension.useThrottledCallback(fn, interval) returns a throttled function you call imperatively (for scroll/resize handlers) rather than a throttled value derived from props or state. The timing logic is the same; the surface changes.requestAnimationFrame cadence. For purely visual throttling (animations, drag previews), pacing commits to the display refresh with requestAnimationFrame instead of a millisecond interval ties updates to frames and pauses automatically in background tabs.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
Build a React hook that returns a throttled copy of a value. As the source value changes — on every keystroke, scroll event, or window resize — the throttled copy updates at most once per interval milliseconds, so anything reading it (an expensive search, a chart redraw) runs on a steady cadence instead of on every change. Throttle is the close cousin of debounce, but they answer different questions: debounce waits for the source to go quiet before committing, while throttle commits on a fixed clock no matter how busy the source is. The catch most people miss: the very last value of a burst must still land, even though it arrived before the interval elapsed.
function useThrottle<T>(value: T, interval: number): T;
The hook returns the throttled value. The first render returns value as-is.
function Search({ query }) {
// `query` may change on every keystroke; `throttled` changes at most
// once every 200ms, so we only re-run the expensive search on a cadence.
const throttled = useThrottle(query, 200);
const results = useMemo(() => expensiveSearch(throttled), [throttled]);
return <ResultList items={results} />;
}
// interval = 200, source changes at t = 0, 50, 90, 130 with values a,b,c,d
// t=0: throttled === 'a' (first value lands immediately)
// t=50: throttled === 'a' (inside the interval — held)
// t=90: throttled === 'a' (still inside — held)
// t=130: throttled === 'a' (still inside — held)
// t=200: throttled === 'd' (interval elapsed — the LATEST value lands)
You'll build a hook that mirrors a value but only lets it change on a fixed clock — at most once per interval — while making sure the very last value of a fast burst still lands.
A value in your component changes far too often to act on directly: a search box fires on every keystroke, a scroll handler fires dozens of times a second. You want a copy of that value that updates on a steady beat — say, once every 200ms — so the expensive work hanging off it runs on a cadence instead of on every change. That's throttle. It's the cousin of debounce, but they're not the same: debounce waits until the source goes quiet and then commits; throttle commits on a fixed clock regardless of how busy the source is. The part that trips people up is the trailing edge — when a burst ends mid-interval, the final value arrived before the clock ticked, and a careless throttle drops it.
Picture two clocks. The source changes whenever it likes. Throttle keeps its own metronome: it lets the first value through immediately, then refuses to commit again until interval ms have passed since the last commit. When a change arrives too early, throttle doesn't discard it — it remembers it as "the latest" and sets a single trailing timer for the time remaining in the window. When that timer fires, it commits whatever the latest value is by then. So the throttled copy moves on the clock, and the last value of any burst is guaranteed to land within roughly one interval.
The obvious version keeps a timestamp of the last commit and, on each render, commits only if enough time has already passed:
const { useState, useRef } = require('react');
function useThrottle(value, interval) {
const [throttled, setThrottled] = useState(value);
const lastRun = useRef(Date.now());
// If a full interval has already elapsed, commit the new value now.
if (Date.now() - lastRun.current >= interval) {
lastRun.current = Date.now();
if (value !== throttled) setThrottled(value);
}
return throttled;
}
This handles the leading edge fine — the first change after a quiet stretch lands right away — but it silently drops the trailing edge. Consider a burst 1, 2, 3 that all arrives inside one interval. Each render checks elapsed >= interval, finds it false, and does nothing. There's no timer scheduled to revisit the decision later, so when the burst stops, 3 is never committed. The throttled value is frozen at its old value until the source happens to change again — which, for the end of a burst, may be never. The fix has to schedule a commit for the time remaining, not just check on the way past.
const { useState, useRef, useEffect } = require('react');
function useThrottle(value, interval) {
const [throttled, setThrottled] = useState(value);
// When we last committed. Starts "now" so the first interval is measured
// from mount. A ref, so updating it never triggers a render.
const lastRun = useRef(Date.now());
// Always holds the freshest value. The trailing timer reads this at fire
// time, so it commits the LATEST value even if more changes arrived after
// it was scheduled.
const latest = useRef(value);
latest.current = value;
useEffect(() => {
const elapsed = Date.now() - lastRun.current;
if (elapsed >= interval) {
// The window is already open — commit immediately (leading edge).
lastRun.current = Date.now();
setThrottled(latest.current);
return undefined;
}
// Too soon. Schedule ONE trailing timer for the time left in the window.
// When it fires it commits latest.current — the freshest value by then.
const id = setTimeout(() => {
lastRun.current = Date.now();
setThrottled(latest.current);
}, interval - elapsed);
// Clear the pending timer if the value changes again or we unmount, so we
// never stack timers and never setState into a dead component.
return () => clearTimeout(id);
}, [value, interval]);
return throttled;
}
module.exports = { useThrottle };
The key shift from the naive version is the trailing setTimeout. Instead of only acting when the window is already open, the effect schedules a commit for exactly the time remaining when the window is still closed. Because each run's cleanup clears the prior timer, a fast burst replaces its own pending timer over and over — so only one commit lands per window, and it commits latest.current, the freshest value at fire time. The latest ref is what lets a timer scheduled at value 1 still commit value 3.
Every time value changes, the effect re-runs and makes the same small decision: is the window open or not?
Take useThrottle(value, 200) where the source goes 0 → 1 at t=0, → 2 at t=50, → 3 at t=90, then stops:
0. useState(0) returns 0 immediately, so the first read is 0. lastRun is set to t=0. The effect runs: elapsed is ~0, which is < 200, so it schedules a trailing timer for ~200ms and the throttled value stays 0.1. The effect's cleanup clears the t=0 timer. latest.current is now 1. elapsed (~50) is still < 200, so it schedules a fresh trailing timer for the ~150ms remaining.2, then quickly 3. Same story: each change clears the previous timer and schedules a new one. latest.current ends up 3. No commit has happened yet — the throttled value is still 0, exactly as throttle promises inside a window.lastRun to now and calls setThrottled(latest.current) — which is 3. The throttled value becomes 3. The burst's final value landed within one interval, and only one commit happened for the whole burst.clearTimeout, so no setThrottled is called into the unmounted component.elapsed >= interval already holds drops the last value of any burst — type 1, 2, 3 fast and the throttled copy never reaches 3. Fix: schedule a setTimeout for interval - elapsed that commits latest.current.value inside the timer instead of a ref. A timer scheduled at value 1 that closes over value will commit 1, even though 3 arrived later. Fix: read latest.current at fire time so the commit always uses the freshest value.clearTimeout in cleanup. Without it, a fast burst stacks one pending timer per change — several fire in a row, and timers fire after unmount and call setState on a dead component. Fix: return () => clearTimeout(id) from the effect.throttle) take { leading, trailing } flags so you can suppress the immediate first commit or the final trailing one. The hook above is leading-and-trailing; gating each behind a flag is a small extension.useThrottledCallback(fn, interval) returns a throttled function you call imperatively (for scroll/resize handlers) rather than a throttled value derived from props or state. The timing logic is the same; the surface changes.requestAnimationFrame cadence. For purely visual throttling (animations, drag previews), pacing commits to the display refresh with requestAnimationFrame instead of a millisecond interval ties updates to frames and pauses automatically in background tabs.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.