ESC

Type to search the knowledge base.

useThrottle Hook

Throttle a changing value in React: at most one update per interval, trailing flush, cleanup, and how this differs from useDebouncedValue.

intermediate7 min read
  • 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 lastFired timestamp. 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 clearTimeout on every value change. That reset is debounce. Clear on unmount (and when interval changes).
  • 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:

  1. Mount with value = 0. useState(0) already matches. lastFired is 0, so remaining is negative. The effect flushes immediately (React bails out if state is Object.is-equal). lastFired becomes now.
  2. At t=10, 20, 30 the source is 10, 20, 30. Each render writes valueRef. remaining is still positive. The first of those schedules setTimeout(flush, remaining). Later ones do not schedule a second timer.
  3. When the timer fires, flush reads valueRef.current (30, not 10) and commits once.
  4. A value that arrives after the window opens (remaining <= 0) flushes on the leading edge again.
  5. Unmount or interval change: 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 lastFired in a ref, commit immediately if the interval has elapsed, and otherwise schedule a single trailing setTimeout for 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 vanilla throttle helper is for handlers. The hook is for data that other effects can depend on.

Further reading

Related guides