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 -
isThrottlingtells 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
| Property | Type | Default | Description |
|---|---|---|---|
interval | number | 500 | Minimum time in milliseconds between function executions |
trailing | boolean | true | If true, also execute when the throttle period ends |
Return Value
| Property | Type | Description |
|---|---|---|
throttledFn | (...args: Parameters<T>) => void | The throttled version of your callback |
isThrottling | boolean | true when in a throttle period |
cancel | () => void | Cancel 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
| Technique | Behavior | Best For |
|---|---|---|
| Debounce | Waits for pause in activity before firing | Search inputs, form validation |
| Throttle | Fires at most once per interval | Scroll 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;