Fiber UI LogoFiberUI

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 - isPending tells 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:

API calls made: 0

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:

Status:Idle

Message Log

No messages sent yet

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

PropertyTypeDefaultDescription
delaynumber500Delay in milliseconds before the callback executes
leadingbooleanfalseIf true, execute immediately on first call

Return Value

PropertyTypeDescription
debouncedFn(...args: Parameters<T>) => voidThe debounced version of your callback
isPendingbooleantrue when a call is waiting to execute
cancel() => voidCancel the pending call
flush() => voidImmediately execute with the last arguments

Common Patterns

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 inactivity

Window Resize Handler

const { debouncedFn: handleResize } = useDebouncedCallback(
    () => recalculateLayout(),
    { delay: 150 },
);

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

Debounce vs Throttle

TechniqueBehaviorBest For
DebounceWaits for pause in activity before firingSearch inputs, form validation
ThrottleFires at most once per interval, ignores the restScroll 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;