Fiber UI LogoFiberUI

useThrottledCallback

A hook for throttling callback functions

A React hook that returns a throttled version of a callback function. The callback executes at most once per interval, regardless of how many times it's called. Perfect for scroll handlers, resize observers, and any high-frequency event handler.

Source Code

View the full hook implementation in the Hook Source Code section below.

Related Hook

Need to throttle a value instead of a function? See useThrottledState.

Features

  • Callback Throttling - Limits how often a function can execute
  • Trailing Edge - Optionally execute one final time when throttle period ends
  • Cancel - Cancel any pending trailing execution
  • Throttling State - isThrottling tells you when in a throttle period
  • SSR Safe - No issues with server-side rendering

Basic Usage

Throttle a scroll handler to improve performance. Notice the dramatic reduction in function calls:

👆 Scroll me rapidly!

Line 1

Line 2

Line 3

Line 4

Line 5

Line 6

Line 7

Line 8

Line 9

Line 10

Line 11

Line 12

Line 13

Line 14

Line 15

Line 16

Line 17

Line 18

Line 19

Line 20

Scroll Events

0

Throttled Calls

0

Scroll Position

0px

useThrottledCallback limits how often the handler runs, improving scroll performance.

"use client";

import { useState, useRef } from "react";
import { useThrottledCallback } from "@repo/hooks/utility/use-throttled-callback";

/* BASIC USAGE - Scroll Handler */
export const Example1 = () => {
    const [scrollY, setScrollY] = useState(0);
    const [callCount, setCallCount] = useState(0);
    const [throttledCallCount, setThrottledCallCount] = useState(0);
    const scrollContainerRef = useRef<HTMLDivElement>(null);

    const { throttledFn: handleScroll } = useThrottledCallback(
        (scrollTop: number) => {
            setScrollY(scrollTop);
            setThrottledCallCount((c) => c + 1);
        },
        { interval: 150 },
    );

    const onScroll = (e: React.UIEvent<HTMLDivElement>) => {
        setCallCount((c) => c + 1);
        handleScroll(e.currentTarget.scrollTop);
    };

    return (
        <div className="flex flex-col gap-4">
            <div
                ref={scrollContainerRef}
                onScroll={onScroll}
                className="bg-muted/30 h-40 overflow-y-auto rounded-md border"
            >
                <div className="p-4" style={{ height: "600px" }}>
                    <p className="text-muted-foreground sticky top-0 text-sm">
                        👆 Scroll me rapidly!
                    </p>
                    <div className="mt-4 space-y-4">
                        {Array.from({ length: 20 }).map((_, i) => (
                            <p
                                key={i}
                                className="text-muted-foreground text-xs"
                            >
                                Line {i + 1}
                            </p>
                        ))}
                    </div>
                </div>
            </div>

            <div className="grid grid-cols-3 gap-4 text-center">
                <div className="bg-muted/50 rounded-md p-3">
                    <p className="text-muted-foreground mb-1 text-xs font-medium">
                        Scroll Events
                    </p>
                    <p className="text-xl font-bold">{callCount}</p>
                </div>
                <div className="rounded-md border border-purple-500/50 bg-purple-500/10 p-3">
                    <p className="mb-1 text-xs font-medium text-purple-600 dark:text-purple-400">
                        Throttled Calls
                    </p>
                    <p className="text-xl font-bold text-purple-600 dark:text-purple-400">
                        {throttledCallCount}
                    </p>
                </div>
                <div className="bg-muted/50 rounded-md p-3">
                    <p className="text-muted-foreground mb-1 text-xs font-medium">
                        Scroll Position
                    </p>
                    <p className="text-xl font-bold">{Math.round(scrollY)}px</p>
                </div>
            </div>

            <p className="text-muted-foreground text-xs">
                <code className="bg-muted rounded px-1">
                    useThrottledCallback
                </code>{" "}
                limits how often the handler runs, improving scroll performance.
            </p>
        </div>
    );
};

Button Spam Protection

With trailing: false, rapid clicks only execute once per interval - perfect for preventing accidental double-submits:

Button Clicks

0

Actual Executions

0

With trailing: false, rapid clicks only execute once per second. Great for preventing accidental double-submits!

"use client";

import { useState } from "react";
import { useThrottledCallback } from "@repo/hooks/utility/use-throttled-callback";
import { MousePointer2, Ban } from "lucide-react";

/* BUTTON SPAM PROTECTION - Prevent Rapid Clicks */
export const Example2 = () => {
    const [clickCount, setClickCount] = useState(0);
    const [actualClicks, setActualClicks] = useState(0);

    const {
        throttledFn: handleClick,
        isThrottling,
        cancel,
    } = useThrottledCallback(
        () => {
            setActualClicks((c) => c + 1);
        },
        { interval: 1000, trailing: false },
    );

    const onClick = () => {
        setClickCount((c) => c + 1);
        handleClick();
    };

    return (
        <div className="flex flex-col gap-4">
            <div className="flex items-center gap-4">
                <button
                    onClick={onClick}
                    className={`inline-flex items-center gap-2 rounded-md px-6 py-3 text-sm font-medium text-white transition-colors ${
                        isThrottling
                            ? "cursor-not-allowed bg-gray-500"
                            : "bg-purple-600 hover:bg-purple-700"
                    }`}
                >
                    <MousePointer2 className="h-4 w-4" />
                    {isThrottling ? "Throttled..." : "Click Me!"}
                </button>
                <button
                    onClick={() => {
                        cancel();
                        setClickCount(0);
                        setActualClicks(0);
                    }}
                    className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1.5 text-sm"
                >
                    <Ban className="h-4 w-4" />
                    Reset
                </button>
            </div>

            <div className="grid grid-cols-2 gap-4 text-center">
                <div className="bg-muted/50 rounded-md p-4">
                    <p className="text-muted-foreground mb-1 text-xs font-medium">
                        Button Clicks
                    </p>
                    <p className="text-3xl font-bold">{clickCount}</p>
                </div>
                <div className="rounded-md border border-purple-500/50 bg-purple-500/10 p-4">
                    <p className="mb-1 text-xs font-medium text-purple-600 dark:text-purple-400">
                        Actual Executions
                    </p>
                    <p className="text-3xl font-bold text-purple-600 dark:text-purple-400">
                        {actualClicks}
                    </p>
                </div>
            </div>

            <p className="text-muted-foreground text-xs">
                With{" "}
                <code className="bg-muted rounded px-1">trailing: false</code>,
                rapid clicks only execute once per second. Great for preventing
                accidental double-submits!
            </p>
        </div>
    );
};

API Reference

Hook Signature

function useThrottledCallback<T extends (...args: any[]) => any>(
    callback: T,
    options?: UseThrottledCallbackOptions,
): UseThrottledCallbackReturn<T>;

Options

PropertyTypeDefaultDescription
intervalnumber500Minimum time in milliseconds between function executions
trailingbooleantrueIf true, also execute when the throttle period ends

Return Value

PropertyTypeDescription
throttledFn(...args: Parameters<T>) => voidThe throttled version of your callback
isThrottlingbooleantrue when in a throttle period
cancel() => voidCancel any pending trailing execution

Common Patterns

Scroll Handler

const { throttledFn: handleScroll } = useThrottledCallback(
    () => {
        updateParallaxEffect(window.scrollY);
    },
    { interval: 16 }, // ~60fps
);

useEffect(() => {
    window.addEventListener("scroll", handleScroll);
    return () => window.removeEventListener("scroll", handleScroll);
}, [handleScroll]);

Resize Handler

const { throttledFn: handleResize } = useThrottledCallback(
    () => {
        recalculateLayout();
    },
    { interval: 100 },
);

useEffect(() => {
    window.addEventListener("resize", handleResize);
    return () => window.removeEventListener("resize", handleResize);
}, [handleResize]);

Submit Button

const { throttledFn: handleSubmit, isThrottling } = useThrottledCallback(
    async () => {
        await submitForm();
    },
    { interval: 2000, trailing: false },
);

<button onClick={handleSubmit} disabled={isThrottling}>
    {isThrottling ? "Please wait..." : "Submit"}
</button>;

Debounce vs Throttle

TechniqueBehaviorBest For
DebounceWaits for pause in activity before firingSearch inputs, form validation
ThrottleFires at most once per intervalScroll handlers, resize events

Hook Source Code

import { useState, useEffect, useCallback, useRef } from "react";

/**
 * Options for the useThrottledCallback hook
 */
export interface UseThrottledCallbackOptions {
    /** Interval in milliseconds between executions (default: 500) */
    interval?: number;
    /** If true, also execute on the trailing edge after throttle period ends */
    trailing?: boolean;
}

/**
 * Return type for useThrottledCallback hook
 */
export interface UseThrottledCallbackReturn<T extends (...args: any[]) => any> {
    /** The throttled function */
    throttledFn: (...args: Parameters<T>) => void;
    /** Whether currently in a throttle period */
    isThrottling: boolean;
    /** Cancel any pending trailing call */
    cancel: () => void;
}

/**
 * A React hook that returns a throttled version of a callback function.
 * The callback will execute at most once per interval, regardless of
 * how many times it's called.
 *
 * @param callback - The function to throttle
 * @param options - Configuration options
 * @returns UseThrottledCallbackReturn object with throttled function
 *
 * @example
 * ```tsx
 * const { throttledFn: handleScroll } = useThrottledCallback(
 *     (e: Event) => {
 *         console.log("Scroll position:", window.scrollY);
 *     },
 *     { interval: 100 }
 * );
 *
 * useEffect(() => {
 *     window.addEventListener("scroll", handleScroll);
 *     return () => window.removeEventListener("scroll", handleScroll);
 * }, [handleScroll]);
 * ```
 */
export function useThrottledCallback<T extends (...args: any[]) => any>(
    callback: T,
    options: UseThrottledCallbackOptions = {},
): UseThrottledCallbackReturn<T> {
    const { interval = 500, trailing = true } = options;

    const [isThrottling, setIsThrottling] = useState(false);
    const lastExecuted = useRef<number>(0);
    const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
    const callbackRef = useRef<T>(callback);
    const lastArgsRef = useRef<Parameters<T> | null>(null);

    // Keep callback ref updated
    callbackRef.current = callback;

    // Cancel any pending trailing call
    const cancel = useCallback(() => {
        if (timeoutRef.current) {
            clearTimeout(timeoutRef.current);
            timeoutRef.current = null;
        }
        setIsThrottling(false);
        lastArgsRef.current = null;
    }, []);

    // The throttled function
    const throttledFn = useCallback(
        (...args: Parameters<T>) => {
            const now = Date.now();
            const timeSinceLastExecution = now - lastExecuted.current;

            lastArgsRef.current = args;

            // If enough time has passed, execute immediately
            if (timeSinceLastExecution >= interval) {
                callbackRef.current(...args);
                lastExecuted.current = now;
                setIsThrottling(false);

                // Clear any pending trailing call
                if (timeoutRef.current) {
                    clearTimeout(timeoutRef.current);
                    timeoutRef.current = null;
                }
            } else {
                // We're in a throttle period
                setIsThrottling(true);

                // Schedule trailing call if enabled and not already scheduled
                if (trailing && !timeoutRef.current) {
                    const timeRemaining = interval - timeSinceLastExecution;
                    timeoutRef.current = setTimeout(() => {
                        if (lastArgsRef.current) {
                            callbackRef.current(...lastArgsRef.current);
                            lastExecuted.current = Date.now();
                        }
                        setIsThrottling(false);
                        timeoutRef.current = null;
                        lastArgsRef.current = null;
                    }, timeRemaining);
                }
            }
        },
        [interval, trailing],
    );

    // Cleanup on unmount
    useEffect(() => {
        return () => {
            if (timeoutRef.current) {
                clearTimeout(timeoutRef.current);
            }
        };
    }, []);

    return {
        throttledFn,
        isThrottling,
        cancel,
    };
}

export default useThrottledCallback;