useDebouncedCallback
EditDelays a callback until calls have stopped while always invoking its latest implementation.
import { useEffect, useLayoutEffect, useMemo, useRef } from "react";
export type DebouncedCallback<Arguments extends unknown[], This = unknown> = (( this: This, ...args: Arguments) => void) & { cancel: () => void; flush: () => void; pending: () => boolean;};
const useIsomorphicLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
/** Returns a debounced callback that always invokes the latest function. */export const useDebouncedCallback = <This, Arguments extends unknown[]>( callback: (this: This, ...args: Arguments) => unknown, delayMs: number = 300,): DebouncedCallback<Arguments, This> => { if (!Number.isFinite(delayMs) || delayMs < 0) { throw new RangeError("delayMs must be a finite, non-negative number"); }
const callbackRef = useRef(callback); const delayRef = useRef(delayMs); const previousDelayRef = useRef(delayMs); const mountedRef = useRef(false);
useIsomorphicLayoutEffect(() => { callbackRef.current = callback; }, [callback]);
const debounced = useMemo<DebouncedCallback<Arguments, This>>(() => { let timer: ReturnType<typeof setTimeout> | undefined; let latestArguments: Arguments | undefined; let latestReceiver: This | undefined;
const invoke = () => { const args = latestArguments; const receiver = latestReceiver; timer = undefined; latestArguments = undefined; latestReceiver = undefined;
if (args !== undefined) { Reflect.apply(callbackRef.current, receiver, args); } };
const wrapped = function (this: This, ...args: Arguments) { if (!mountedRef.current) return; latestArguments = args; latestReceiver = this; if (timer !== undefined) clearTimeout(timer); timer = setTimeout(invoke, delayRef.current); };
return Object.assign(wrapped, { cancel: () => { if (timer !== undefined) clearTimeout(timer); timer = undefined; latestArguments = undefined; latestReceiver = undefined; }, flush: () => { if (timer === undefined) return; clearTimeout(timer); invoke(); }, pending: () => timer !== undefined, }); }, []);
useIsomorphicLayoutEffect(() => { if (!Object.is(previousDelayRef.current, delayMs)) { debounced.cancel(); previousDelayRef.current = delayMs; } delayRef.current = delayMs; }, [debounced, delayMs]);
useIsomorphicLayoutEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; debounced.cancel(); }; }, [debounced]);
return debounced;};Download
Section titled “Download”wget -O src/hooks/useDebouncedCallback.ts https://raw.githubusercontent.com/jrTilak/lazykit/HEAD/registry/react-hooks/useDebouncedCallback.tstemp_dir="$(mktemp -d)" && bunx degit jrTilak/lazykit/registry/react-hooks "$temp_dir" && mv "$temp_dir/useDebouncedCallback.ts" src/hooks/useDebouncedCallback.ts && rm -r "$temp_dir"Examples
Section titled “Examples”import { useState } from "react";
import { useDebouncedCallback } from "./useDebouncedCallback";
export const AutosaveInput = () => { const [status, setStatus] = useState("Nothing to save"); const save = useDebouncedCallback((value: string) => { setStatus(`Saved “${value}”`); }, 500);
return ( <label> Note <input onChange={(event) => { setStatus("Waiting…"); save(event.currentTarget.value); }} /> <span>{status}</span> </label> );};callback((this: T, ...args: A) => unknown) — Function invoked with the receiver and arguments from the latest call.delayMs(number, default:300) — Finite, non-negative quiet period in milliseconds.
Returns
Section titled “Returns”A receiver- and argument-safe debounced function with:
cancel()— Discards a pending invocation.flush()— Immediately runs a pending invocation.pending()— Reports whether an invocation is waiting.
The returned function keeps the same identity across renders. Changing the delay or unmounting cancels pending work.