React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

throttleperformancescrollresizerate-limitcustom-hook

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:

  • useThrottledCallback fires the scroll handler at most every 100 ms, keeping the UI responsive without flooding state updates
  • useThrottledValue smooths 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

BehaviorDebounceThrottle
When it firesAfter input stops for N msAt most once every N ms
Best forSearch input, form validationScroll, resize, drag
ResponsivenessFeels delayedFeels smooth
Trailing valueOnly the lastLeading + trailing

Parameters & Return Values

useThrottledValue

ParameterTypeDefaultDescription
valueTThe value to throttle
intervalnumberMinimum ms between updates
ReturnsTThe throttled value

useThrottledCallback

ParameterTypeDefaultDescription
callback(...args) => voidFunction to throttle
intervalnumberMinimum ms between calls
ReturnsT & \{ 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 T on 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 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.
  • Timer driftsetTimeout is not perfectly accurate. For animation-critical code, Fix: use requestAnimationFrame instead.
  • SSR crash — Accessing window during server render throws. Fix: Guard with typeof window !== "undefined" or initialize to a safe default.

Alternatives

PackageHook NameNotes
usehooks-tsuseThrottleValue-only throttle
ahooksuseThrottle, useThrottleFnFull-featured, leading/trailing config
@uidotdev/usehooksuseThrottleMinimal value throttle
lodash_.throttleNot a hook; wrap in useRef
use-debounceuseThrottledCallbackPart of the debounce package

FAQs

What is the difference between useThrottledValue and useThrottledCallback?
  • 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.
  • 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.
  • setTimeout also has a minimum delay of ~4 ms in most browsers.
  • For frame-perfect updates, use requestAnimationFrame instead.
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 window during server-side rendering throws a ReferenceError.
  • Guard with typeof window !== "undefined" before reading window.scrollY or attaching listeners.
  • The useEffect in 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?
  • requestAnimationFrame fires 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.