useDebouncedCallback
A hook for debouncing callback functions
A React hook that returns a debounced version of a callback function. The callback will only be invoked after the specified delay has passed without being called again. Perfect for API calls, event handlers, and any function you want to limit.
Source Code
View the full hook implementation in the Hook Source Code section below.
Related Hook
Need to debounce a value instead of a function? See useDebouncedState.
Features
- Callback Debouncing - Wraps functions to debounce their execution
- Pending State -
isPendingtells you when a call is waiting to execute - Cancel & Flush -
cancel()discards pending calls,flush()executes immediately - Leading Edge - Optional immediate first execution, then debounce subsequent calls
- SSR Safe - No issues with server-side rendering
Basic Usage
Use useDebouncedCallback when you want to debounce a function instead of a value. This is ideal for API calls - notice how the call counter only increases after you stop typing:
Type to search for users...
Using useDebouncedCallback to debounce the search function, reducing API calls while typing.
"use client";
import { useState } from "react";
import { useDebouncedCallback } from "@repo/hooks/utility/use-debounced-callback";
import { Search, Loader2 } from "lucide-react";
/* BASIC USAGE - API Search Simulation */
export const Example1 = () => {
const [query, setQuery] = useState("");
const [results, setResults] = useState<string[]>([]);
const [searchCount, setSearchCount] = useState(0);
const { debouncedFn: debouncedSearch, isPending } = useDebouncedCallback(
(searchQuery: string) => {
// Simulated API call
setSearchCount((c) => c + 1);
if (searchQuery.trim()) {
setResults([
`Result for "${searchQuery}" #1`,
`Result for "${searchQuery}" #2`,
`Result for "${searchQuery}" #3`,
]);
} else {
setResults([]);
}
},
{ delay: 400 },
);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setQuery(value);
debouncedSearch(value);
};
return (
<div className="flex flex-col gap-4">
<div className="relative">
<Search className="text-muted-foreground absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" />
<input
type="text"
value={query}
onChange={handleChange}
placeholder="Search users..."
className="border-input bg-background w-full rounded-md border py-2 pl-10 pr-10 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
{isPending && (
<Loader2 className="absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 animate-spin text-blue-500" />
)}
</div>
<div className="text-muted-foreground text-xs">
API calls made:{" "}
<span className="font-mono font-medium text-blue-500">
{searchCount}
</span>
</div>
<div className="bg-muted/30 min-h-[100px] rounded-md p-3">
{results.length > 0 ? (
<ul className="space-y-2">
{results.map((result, i) => (
<li
key={i}
className="bg-background rounded border px-3 py-2 text-sm"
>
{result}
</li>
))}
</ul>
) : (
<p className="text-muted-foreground text-center text-sm italic">
{query ? "Searching..." : "Type to search for users..."}
</p>
)}
</div>
<p className="text-muted-foreground text-xs">
Using{" "}
<code className="bg-muted rounded px-1">
useDebouncedCallback
</code>{" "}
to debounce the search function, reducing API calls while
typing.
</p>
</div>
);
};
Cancel and Flush
Use cancel() to prevent a pending callback from executing, or flush() to execute it immediately:
Message Log
Cancel prevents the pending callback. Flush executes it immediately.
"use client";
import { useState } from "react";
import { useDebouncedCallback } from "@repo/hooks/utility/use-debounced-callback";
import { Ban, Zap, Send } from "lucide-react";
/* CANCEL AND FLUSH - Control Functions */
export const Example2 = () => {
const [inputValue, setInputValue] = useState("");
const [logs, setLogs] = useState<string[]>([]);
const { debouncedFn, isPending, cancel, flush } = useDebouncedCallback(
(value: string) => {
const timestamp = new Date().toLocaleTimeString();
setLogs((prev) => [
...prev.slice(-4),
`[${timestamp}] Sent: "${value}"`,
]);
},
{ delay: 2000 },
);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setInputValue(value);
debouncedFn(value);
};
return (
<div className="flex flex-col gap-4">
<div className="flex gap-2">
<input
type="text"
value={inputValue}
onChange={handleChange}
placeholder="Type a message (2s delay)..."
className="border-input bg-background flex-1 rounded-md border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
onClick={cancel}
disabled={!isPending}
className="inline-flex items-center gap-1.5 rounded-md bg-red-600 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-red-700 disabled:opacity-50"
title="Cancel pending send"
>
<Ban className="h-4 w-4" />
</button>
<button
onClick={flush}
disabled={!isPending}
className="inline-flex items-center gap-1.5 rounded-md bg-green-600 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-green-700 disabled:opacity-50"
title="Send immediately"
>
<Zap className="h-4 w-4" />
</button>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-sm">Status:</span>
{isPending ? (
<span className="inline-flex items-center gap-1.5 rounded-full bg-yellow-500/20 px-2.5 py-0.5 text-xs font-medium text-yellow-600 dark:text-yellow-400">
<Send className="h-3 w-3" />
Sending in 2s...
</span>
) : (
<span className="bg-muted text-muted-foreground inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium">
Idle
</span>
)}
</div>
<div className="bg-muted/30 rounded-md p-3">
<p className="text-muted-foreground mb-2 text-xs font-medium">
Message Log
</p>
<div className="space-y-1 font-mono text-xs">
{logs.length === 0 ? (
<span className="text-muted-foreground italic">
No messages sent yet
</span>
) : (
logs.map((log, i) => (
<div key={i} className="text-foreground">
{log}
</div>
))
)}
</div>
</div>
<p className="text-muted-foreground text-xs">
<strong>Cancel</strong> prevents the pending callback.{" "}
<strong>Flush</strong> executes it immediately.
</p>
</div>
);
};
API Reference
Hook Signature
function useDebouncedCallback<T extends (...args: any[]) => any>(
callback: T,
options?: UseDebouncedCallbackOptions,
): UseDebouncedCallbackReturn<T>;Options
| Property | Type | Default | Description |
|---|---|---|---|
delay | number | 500 | Delay in milliseconds before the callback executes |
leading | boolean | false | If true, execute immediately on first call |
Return Value
| Property | Type | Description |
|---|---|---|
debouncedFn | (...args: Parameters<T>) => void | The debounced version of your callback |
isPending | boolean | true when a call is waiting to execute |
cancel | () => void | Cancel the pending call |
flush | () => void | Immediately execute with the last arguments |
Common Patterns
API Search
const { debouncedFn: search } = useDebouncedCallback(
async (query: string) => {
const results = await fetch(`/api/search?q=${query}`);
setResults(await results.json());
},
{ delay: 300 },
);
<input onChange={(e) => search(e.target.value)} />;Form Auto-Save
const { debouncedFn: autoSave } = useDebouncedCallback(
(data: FormData) => saveToServer(data),
{ delay: 1000 },
);
// Call autoSave on every change, but it only saves after 1s of inactivityWindow Resize Handler
const { debouncedFn: handleResize } = useDebouncedCallback(
() => recalculateLayout(),
{ delay: 150 },
);
useEffect(() => {
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, [handleResize]);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, ignores the rest | Scroll handlers, resize events |
Hook Source Code
import { useState, useEffect, useCallback, useRef } from "react";
/**
* Options for the useDebouncedCallback hook
*/
export interface UseDebouncedCallbackOptions {
/** Delay in milliseconds before the callback executes (default: 500) */
delay?: number;
/** If true, execute immediately on the first call, then debounce subsequent calls */
leading?: boolean;
}
/**
* Return type for useDebouncedCallback hook
*/
export interface UseDebouncedCallbackReturn<T extends (...args: any[]) => any> {
/** The debounced function */
debouncedFn: (...args: Parameters<T>) => void;
/** Whether a call is pending */
isPending: boolean;
/** Cancel the pending call */
cancel: () => void;
/** Immediately execute with the last arguments */
flush: () => void;
}
/**
* A React hook that returns a debounced version of a callback function.
* The callback will only be invoked after the specified delay has passed
* without being called again.
*
* @param callback - The function to debounce
* @param options - Configuration options
* @returns UseDebouncedCallbackReturn object with debounced function and controls
*
* @example
* ```tsx
* const { debouncedFn: handleSearch } = useDebouncedCallback(
* (query: string) => {
* console.log("Searching for:", query);
* fetchResults(query);
* },
* { delay: 300 }
* );
*
* <input onChange={(e) => handleSearch(e.target.value)} />
* ```
*/
export function useDebouncedCallback<T extends (...args: any[]) => any>(
callback: T,
options: UseDebouncedCallbackOptions = {},
): UseDebouncedCallbackReturn<T> {
const { delay = 500, leading = false } = options;
const [isPending, setIsPending] = useState(false);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const callbackRef = useRef<T>(callback);
const lastArgsRef = useRef<Parameters<T> | null>(null);
const isFirstCall = useRef(true);
// Keep callback ref updated
callbackRef.current = callback;
// Cancel pending timeout
const cancel = useCallback(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
setIsPending(false);
lastArgsRef.current = null;
}, []);
// Flush and execute immediately
const flush = useCallback(() => {
if (timeoutRef.current && lastArgsRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
callbackRef.current(...lastArgsRef.current);
lastArgsRef.current = null;
}
setIsPending(false);
}, []);
// The debounced function
const debouncedFn = useCallback(
(...args: Parameters<T>) => {
lastArgsRef.current = args;
// Handle leading edge
if (leading && isFirstCall.current) {
isFirstCall.current = false;
callbackRef.current(...args);
return;
}
isFirstCall.current = false;
setIsPending(true);
// Clear previous timeout
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
// Set new timeout
timeoutRef.current = setTimeout(() => {
callbackRef.current(...args);
setIsPending(false);
timeoutRef.current = null;
lastArgsRef.current = null;
}, delay);
},
[delay, leading],
);
// Cleanup on unmount
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
return {
debouncedFn,
isPending,
cancel,
flush,
};
}
export default useDebouncedCallback;