All code helpers
FrontendTypeScript

useDebounce hooks for React

Wait until the user stops typing before firing a search or autosave. Includes a value version for derived state and a callback version for event handlers.

#react#hooks#debounce#performance
hooks/use-debounce.ts
import { useCallback, useEffect, useRef, useState } from "react";

/** Returns `value` only after it has stopped changing for `delayMs`. */
export function useDebouncedValue<T>(value: T, delayMs = 300): T {
  const [debounced, setDebounced] = useState(value);

  useEffect(() => {
    const timer = setTimeout(() => setDebounced(value), delayMs);
    return () => clearTimeout(timer);
  }, [value, delayMs]);

  return debounced;
}

/** Returns a stable function that runs `fn` once calls stop for `delayMs`. */
export function useDebouncedCallback<A extends unknown[]>(
  fn: (...args: A) => void,
  delayMs = 300,
) {
  const fnRef = useRef(fn);
  const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);

  // Always call the latest fn without resetting the timer on every render
  useEffect(() => {
    fnRef.current = fn;
  }, [fn]);

  // Don't fire after the component unmounts
  useEffect(() => () => clearTimeout(timerRef.current), []);

  return useCallback(
    (...args: A) => {
      clearTimeout(timerRef.current);
      timerRef.current = setTimeout(() => fnRef.current(...args), delayMs);
    },
    [delayMs],
  );
}

Usage

TSX
"use client";
import { useEffect, useState } from "react";
import { useDebouncedValue } from "@/hooks/use-debounce";

export function ProductSearch() {
  const [query, setQuery] = useState("");
  const [results, setResults] = useState<{ id: string; name: string }[]>([]);
  const debouncedQuery = useDebouncedValue(query, 250);

  useEffect(() => {
    if (!debouncedQuery) return setResults([]);
    const controller = new AbortController();
    fetch(`/api/products?q=${encodeURIComponent(debouncedQuery)}`, { signal: controller.signal })
      .then((res) => res.json())
      .then(setResults)
      .catch(() => {});
    return () => controller.abort(); // ignore responses for stale queries
  }, [debouncedQuery]);

  return (
    <>
      <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search" />
      <ul>{results.map((p) => <li key={p.id}>{p.name}</li>)}</ul>
    </>
  );
}

How it works & gotchas

  • 01Debounce the request, not the input. The text field should stay controlled and instant; only the expensive work waits.
  • 02Pair debouncing with AbortController. A slow response for an old query can otherwise arrive after a newer one and overwrite it.
  • 03The callback version keeps the latest fn in a ref, so passing an inline arrow function doesn't restart the timer on every render.
  • 04For scroll or resize handlers that should fire steadily while active, you want throttling, not debouncing.

Need this wired into a real system?

We build and harden production software end to end.

Talk to an engineer