Effects

useDebounceCallback

Debounces a callback, with cancel, flush and isPending.

Demo

keystrokes
0
callback runs
0
last searched

Usage

use-debounce-callback.tsxtsx
import { useState } from "react";
import { useDebounceCallback } from "hookli";

export function Demo() {
  const [query, setQuery] = useState("");

  const search = useDebounceCallback((q: string) => {
    console.log("searching", q);
  }, 600);

  return (
    <input
      value={query}
      onChange={(e) => {
        setQuery(e.target.value);
        search(e.target.value);
      }}
    />
  );
}

API

useDebounceCallback.d.tsts
useDebounceCallback<Args, R>(fn: (...args: Args) => R, delayMs?: number, options?: DebounceOptions): DebouncedState<Args, R>

Parameters

NameTypeDefaultDescription
fn(...args: Args) => RThe function to debounce. The returned function keeps a stable identity across renders but always invokes the latest fn.
delayMsnumber500Milliseconds to wait after the last call before fn runs.
optionsDebounceOptions{}Leading/trailing edge and maxWait behaviour. See the table below.

Returns

NameTypeDescription
debouncedDebouncedState<Args, R>A debounced version of fn with a stable identity, carrying cancel, flush and isPending. Any pending call is cancelled on unmount.

DebounceOptions

Controls how the debounced invocation is scheduled.

NameTypeDescription
leadingbooleanInvoke on the leading edge — run once immediately on the first call of a burst.
trailingbooleanInvoke on the trailing edge — run after the burst settles.
maxWaitnumberThe maximum time the callback may be delayed before it is forced to run, even during a continuous burst.

DebouncedState<Args, R>

The debounced function, plus manual control methods.

NameTypeDescription
(...args)(...args: Args) => R | undefinedCalling it schedules an invocation and returns the last computed result — undefined before the first run.
cancel() => voidCancels any pending trailing invocation.
flush() => R | undefinedImmediately runs any pending invocation and returns its result.
isPending() => booleanWhether a trailing invocation is currently scheduled.

Hook

The implementation, straight from hookli. Copy it, or install the package.

src/hooks/use-debounce-callback/use-debounce-callback.tstsx
import { useEffect, useMemo, useRef } from "react";
import { useEventCallback } from "../use-event-callback/use-event-callback";
import { useUnmount } from "../use-unmount/use-unmount";

export interface DebounceOptions {
  /** Invoke on the leading edge of the timeout. Defaults to `false`. */
  leading?: boolean;
  /** Invoke on the trailing edge of the timeout. Defaults to `true`. */
  trailing?: boolean;
  /** Maximum time the callback may be delayed before it is forced to run. */
  maxWait?: number;
}

export interface DebouncedState<Args extends unknown[], R> {
  (...args: Args): R | undefined;
  cancel: () => void;
  flush: () => R | undefined;
  isPending: () => boolean;
}

export const useDebounceCallback = <Args extends unknown[], R>(
  fn: (...args: Args) => R,
  delayMs = 500,
  options: DebounceOptions = {},
): DebouncedState<Args, R> => {
  const timeoutId = useRef<ReturnType<typeof setTimeout>>();
  const maxTimeoutId = useRef<ReturnType<typeof setTimeout>>();
  const lastArgs = useRef<Args>();
  const lastResult = useRef<R>();
  const lastInvokeTime = useRef(0);

  const { leading = false, trailing = true, maxWait } = options;
  const latestFn = useEventCallback(fn);

  useEffect(() => {
    lastInvokeTime.current = 0;
  }, [delayMs, leading, trailing, maxWait]);

  const debounced = useMemo(() => {
    const invoke = (): R | undefined => {
      const args = lastArgs.current;
      if (!args) return undefined;
      lastArgs.current = undefined;
      lastInvokeTime.current = Date.now();
      lastResult.current = latestFn(...args);
      return lastResult.current;
    };

    const clearTimers = () => {
      if (timeoutId.current !== undefined) {
        clearTimeout(timeoutId.current);
        timeoutId.current = undefined;
      }
      if (maxTimeoutId.current !== undefined) {
        clearTimeout(maxTimeoutId.current);
        maxTimeoutId.current = undefined;
      }
    };

    const trailingEdge = () => {
      clearTimers();
      if (trailing && lastArgs.current) invoke();
      else lastArgs.current = undefined;
    };

    const state = Object.assign(
      (...args: Args): R | undefined => {
        lastArgs.current = args;
        const isFirstCall =
          timeoutId.current === undefined && maxTimeoutId.current === undefined;
        if (timeoutId.current !== undefined) clearTimeout(timeoutId.current);
        if (leading && isFirstCall) invoke();
        timeoutId.current = setTimeout(trailingEdge, delayMs);
        if (maxWait !== undefined && maxTimeoutId.current === undefined) {
          maxTimeoutId.current = setTimeout(() => {
            clearTimers();
            if (lastArgs.current) invoke();
          }, maxWait);
        }
        return lastResult.current;
      },
      {
        cancel: () => {
          clearTimers();
          lastArgs.current = undefined;
        },
        flush: (): R | undefined => {
          if (timeoutId.current === undefined) return lastResult.current;
          clearTimers();
          return lastArgs.current ? invoke() : lastResult.current;
        },
        isPending: () =>
          timeoutId.current !== undefined && lastArgs.current !== undefined,
      },
    );

    return state;
  }, [delayMs, leading, trailing, maxWait, latestFn]);

  useUnmount(() => {
    debounced.cancel();
  });

  return debounced;
};

View source on GitHub

Related hooks