July 31, 2026

By ReactUse Team

React useThrottle Hook: Throttle Values & Callbacks (2026)

A scroll listener fires at whatever rate the compositor feels like — often 60 times a second, sometimes 120. mousemove is worse. Feed either straight into setState and every wiggle of the wheel re-renders your component tree at frame rate; feed it into an analytics call or a network request and you’ve built a tiny DDoS against your own backend. Debouncing is the wrong fix here: a debounced scroll handler waits for the scrolling to stop, so your reading-progress bar freezes mid-scroll and jumps at the end. What you want is a steady cadence — run this at most once every N milliseconds, while the events keep coming. That’s throttling.

useThrottle and useThrottleFn from @reactuses/core are that cadence as hooks — one for values, one for callbacks — built on lodash’s battle-tested throttle and wrapped so the two classic React failure modes (stale closures, timers that outlive the component) can’t happen. This post walks the real implementation, the leading/trailing knobs, the cancel/flush escape hatches, and one mount-timing subtlety the test suite pins down. TypeScript-first.

useThrottle — Throttle a Value

useThrottle takes a value that changes too fast and returns a copy that changes at a civilized pace:

import { useState } from 'react';
import { useThrottle } from '@reactuses/core';

function MarkdownEditor() {
  const [source, setSource] = useState('');
  const throttledSource = useThrottle(source, 500);

  return (
    <div className="editor">
      <textarea value={source} onChange={e => setSource(e.target.value)} />
      {/* re-parses at most twice per second, even while typing full speed */}
      <Preview markdown={throttledSource} />
    </div>
  );
}

The input stays perfectly responsive — source updates on every keystroke. Only the expensive consumer is throttled: throttledSource updates immediately on the first change, then at most once per 500 ms while changes keep arriving, and settles on the final value once they stop. Compare the debounced version of this editor: the preview would go blank-faced during typing and only catch up in the pauses. Throttle keeps it live, just at a lower refresh rate.

The signature:

function useThrottle<T>(value: T, wait?: number, options?: ThrottleSettings): T;

ThrottleSettings is lodash’s — { leading?: boolean; trailing?: boolean } — and we’ll get to it below.

useThrottleFn — Throttle a Callback

When the thing to slow down is a function rather than a value, useThrottleFn wraps it and hands back controls:

import { useState } from 'react';
import { useThrottleFn, useEventListener } from '@reactuses/core';

function ScrollSpy({ sectionIds }: { sectionIds: string[] }) {
  const [active, setActive] = useState(sectionIds[0]);

  const { run } = useThrottleFn(() => {
    setActive(computeActiveSection(sectionIds, window.scrollY));
  }, 200);

  useEventListener('scroll', run);

  return <TableOfContents ids={sectionIds} active={active} />;
}

The scroll event fires at frame rate; computeActiveSection runs five times a second. The return value is an object with three functions:

const { run, cancel, flush } = useThrottleFn(fn, wait, options);
  • run(...args) — the throttled function. Same parameters as fn, and it returns fn’s result (the most recent one, when a call gets suppressed — standard lodash semantics).
  • cancel() — drop whatever trailing call is pending. A “Reset” button that clears the UI shouldn’t be followed 200 ms later by a ghost update from the last scroll event; call cancel() when the interaction is abandoned.
  • flush() — the opposite: don’t wait for the window to close, run the pending call now. Classic use: throttled autosave plus flush() on “Submit”, so the final state is persisted before navigation, not 2 seconds after.

These aren’t decorative — the library’s test suite drives a full timeline through run/cancel/flush with fake timers and pins every intermediate count: the leading call fires synchronously, suppressed calls collapse into one trailing call carrying the latest arguments, cancel() really does drop the pending call, and flush() really does fire it early.

Under the Hood: lodash Plus Two Fixes

The implementation is short enough to read over coffee:

export function useThrottleFn<T extends (...args: any) => any>(
  fn: T, wait?: number, options?: ThrottleSettings,
) {
  const fnRef = useLatest(fn);

  const throttled = useMemo(
    () =>
      throttle(
        (...args: [...Parameters<T>]): ReturnType<T> => {
          return fnRef.current(...args);
        },
        wait,
        options,
      ),
    [wait, JSON.stringify(options)],
  );

  useUnmount(() => {
    throttled.cancel();
  });

  return { run: throttled, cancel: throttled.cancel, flush: throttled.flush };
}

The throttling engine is lodash-es’s throttle — timing logic with a decade of production mileage, not a hand-rolled setTimeout dance. What the hook adds is exactly the two things that go wrong when you call lodash.throttle inside a component yourself:

  1. No stale closures. The naive useMemo(() => throttle(fn, wait), []) freezes the first render’s fn — with its first-render props and state — for the component’s lifetime. Here the memoized throttle calls fnRef.current, a useLatest ref that re-points at the newest fn every render. Timing state lives in one stable throttle instance; the code it invokes is always current.
  2. No timers outliving the component. useUnmount calls throttled.cancel(), so a pending trailing call can’t fire into an unmounted component. The test suite asserts the timer count is literally zero after unmount.

One small delight in the deps array: JSON.stringify(options). You can pass { trailing: false } inline — a fresh object every render — without recreating the throttle instance, because the memo compares the options by content, not identity. And useThrottle itself is just this hook pointed at state — useThrottleFn(() => setThrottled(value), wait, options) with an effect calling run() whenever value changes. One timing engine, two shapes.

Tuning: leading and trailing

Both edges default to true, which is the behavior you usually want — instant first response, no lost final value:

useThrottle(value, 500);                      // fire now, then every ≤500ms, then final value
useThrottle(value, 500, { leading: false });  // skip the instant first update
useThrottle(value, 500, { trailing: false }); // skip the settle-to-final-value update
  • leading: false delays the first invocation to the end of the window. Useful when the first event of a burst isn’t meaningful on its own — e.g. reporting “user is scrolling” analytics where you’d rather not fire on a single wheel tick.
  • trailing: false means calls made mid-window are dropped rather than deferred. Fine for continuous streams where the next window will bring a fresh reading anyway; wrong for anything where the last value matters (your progress bar would stop just short of 100%).
  • Both false is a lodash trap — the function can only fire if called when no window is open, which for a steady event stream means almost never. Don’t.

The Mount Gotcha — the First Change Can Wait

Here’s the subtlety worth knowing about useThrottle (the value version). Internally it calls run() in an effect on mount — that leading call just re-sets the initial value, invisible. But it also opens the throttle window. The consequence: a value change arriving within wait of mount doesn’t update instantly, leading edge or no — it’s mid-window, so it waits for the trailing edge. The test suite states it plainly:

const { result, rerender } = renderHook(props => useThrottle(props, 100), {
  initialProps: 0,
});
rerender(1);                     // change right after mount
jest.advanceTimersByTime(50);
expect(result.current).toBe(0);  // still the old value — deferred to t=100

After that first window expires, a change that arrives in quiet air gets its own leading edge and shows up immediately. So in steady state useThrottle feels instant-then-throttled, exactly as advertised — but if your component mounts and the value changes in the same breath (hydration handoffs, an immediate fetch result), that first change lands up to wait ms late. If that matters, shorten wait or throttle the source with useThrottleFn instead of the value.

Throttle or Debounce?

Thirty-second version, since they’re the two ends of one spectrum:

  • Debounce = “wait for silence.” Nothing happens until the events stop for wait ms. Right for search-as-you-type, autosave, resize-finished layout work — cases where only the final state matters. That’s useDebounce / useDebounceFn.
  • Throttle = “steady heartbeat.” Runs during the activity, at a capped rate. Right for scroll position, mouse tracking, drag feedback, progress reporting — cases where the user needs to see something happening while it happens.

The tell: if a debounced version of your feature feels frozen during interaction, you wanted throttle. The full decision guide with both hooks side by side is in Debounce vs Throttle in React.

The Rate-Limiting Family

  • useDebounce / useDebounceFn — the same value/callback pair, wait-for-silence timing, same lodash core and same stale-closure/unmount fixes.
  • useRafFn — throttling to the display’s rhythm instead of a millisecond budget: runs a callback once per animation frame. For work that feeds rendering (element highlighting, canvas drawing), one-per-frame beats any hand-picked wait.
  • useRafStateuseState whose setter commits on the next frame; multiple high-frequency sets per frame collapse into one render. The lightest-touch fix for mousemove-driven state.
  • useScroll and useMouse — position-tracking hooks you’ll often find on the input side of a throttle.

SSR Safety

useThrottleFn creates the lodash throttle during render, but creating it starts no timers — timers start when run() is called, and every call site lives in an effect or an event handler, which never execute during server rendering. No window, no document, no clock touched on the server: your Next.js / Remix build renders the initial value and hydrates cleanly, and throttling wakes up with the client. SSR-safe by construction, like every hook in @reactuses/core.

Takeaways

  • Throttle is a cadence, debounce is a wait. For anything the user watches during the interaction — scroll, drag, mouse, live preview — reach for useThrottle / useThrottleFn, not debounce.
  • Values and callbacks are the same engine in two shapes: useThrottle is literally useThrottleFn pointed at a setState.
  • The wrapper earns its keep twice: useLatest kills stale closures, useUnmount cancels pending timers — the two bugs every hand-rolled lodash.throttle-in-React eventually ships.
  • cancel() and flush() are the escape hatches — drop the pending call when the interaction is abandoned, force it when the user commits.
  • Mind the mount window: a value change within wait of mount waits for the trailing edge — verified by the test suite, not vibes.
  • SSR-safe with nothing to configure — no timers, no browser globals until the client takes over.

Install @reactuses/core, point useThrottle at your noisiest value, and give your render loop a pulse instead of a seizure.