React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

debounceperformancesearchinputdelaycustom-hook

useDebounce — Delay value updates or callback execution until input settles

Recipe

Two complementary hooks: useDebouncedValue delays a reactive value; useDebouncedCallback delays a function call.

import { useState, useEffect, useRef, useCallback, useMemo } from "react";
 
/**
 * useDebouncedValue
 * Returns a debounced copy of `value` that only updates
 * after `delay` ms of inactivity.
 */
function useDebouncedValue<T>(value: T, delay: number): T {
  const [debounced, setDebounced] = useState(value);
 
  useEffect(() => {
    const id = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(id);
  }, [value, delay]);
 
  return debounced;
}
 
/**
 * useDebouncedCallback
 * Returns a stable, debounced version of `callback`.
 * Supports leading-edge invocation via `options.leading`.
 */
function useDebouncedCallback<T extends (...args: any[]) => void>(
  callback: T,
  delay: number,
  options: { leading?: boolean } = {}
): T & { cancel: () => void; flush: () => void } {
  const { leading = false } = options;
  const callbackRef = useRef(callback);
  const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  const lastArgsRef = useRef<Parameters<T> | null>(null);
  const leadingFiredRef = useRef(false);
 
  // Always keep the latest callback
  useEffect(() => {
    callbackRef.current = callback;
  }, [callback]);
 
  // Cleanup on unmount
  useEffect(() => {
    return () => {
      if (timerRef.current) clearTimeout(timerRef.current);
    };
  }, []);
 
  const cancel = useCallback(() => {
    if (timerRef.current) clearTimeout(timerRef.current);
    timerRef.current = null;
    lastArgsRef.current = null;
    leadingFiredRef.current = false;
  }, []);
 
  const flush = useCallback(() => {
    if (lastArgsRef.current) {
      callbackRef.current(...lastArgsRef.current);
    }
    cancel();
  }, [cancel]);
 
  const debounced = useCallback(
    (...args: Parameters<T>) => {
      lastArgsRef.current = args;
 
      if (leading && !leadingFiredRef.current) {
        leadingFiredRef.current = true;
        callbackRef.current(...args);
      }
 
      if (timerRef.current) clearTimeout(timerRef.current);
 
      timerRef.current = setTimeout(() => {
        if (!leading) {
          callbackRef.current(...args);
        }
        leadingFiredRef.current = false;
        timerRef.current = null;
        lastArgsRef.current = null;
      }, delay);
    },
    [delay, leading]
  ) as T & { cancel: () => void; flush: () => void };
 
  debounced.cancel = cancel;
  debounced.flush = flush;
 
  return debounced;
}

When to reach for this: You need to avoid hammering an API on every keystroke (search-as-you-type), or you want to batch rapid-fire events like resize or scroll into a single update.

Working Example

"use client";
 
import { useState } from "react";
 
function SearchInput() {
  const [query, setQuery] = useState("");
  const debouncedQuery = useDebouncedValue(query, 300);
 
  // Only fires network request when debouncedQuery changes
  useEffect(() => {
    if (!debouncedQuery) return;
    fetch(`/api/search?q=${encodeURIComponent(debouncedQuery)}`)
      .then((res) => res.json())
      .then((data) => console.log(data));
  }, [debouncedQuery]);
 
  return (
    <input
      value={query}
      onChange={(e) => setQuery(e.target.value)}
      placeholder="Search..."
    />
  );
}
 
function SaveButton() {
  const debouncedSave = useDebouncedCallback(
    (content: string) => {
      fetch("/api/save", {
        method: "POST",
        body: JSON.stringify({ content }),
      });
    },
    1000,
    { leading: true }
  );
 
  return (
    <button onClick={() => debouncedSave("draft content")}>
      Save (debounced)
    </button>
  );
}

What this demonstrates:

  • useDebouncedValue delays the search query so the API is called only after the user stops typing for 300 ms
  • useDebouncedCallback with leading: true fires immediately on first click, then ignores rapid re-clicks for 1 second
  • Both hooks clean up timers automatically on unmount

Deep Dive

How It Works

  • useDebouncedValue sets a setTimeout every time value changes. The cleanup function in useEffect clears the previous timer, so only the last update within delay ms actually lands in state.
  • useDebouncedCallback stores the latest callback in a ref so callers never need to worry about stale closures. A single timer ref is reused across calls.
  • The leading edge option fires the callback immediately on the first invocation, then suppresses further calls until the delay elapses without activity.
  • cancel() clears any pending invocation. flush() invokes the pending callback immediately and resets state.
  • Both hooks clean up on unmount to prevent "setState on unmounted component" warnings.

Parameters & Return Values

useDebouncedValue

ParameterTypeDefaultDescription
valueTThe value to debounce
delaynumberMilliseconds to wait
ReturnsTThe debounced value

useDebouncedCallback

ParameterTypeDefaultDescription
callback(...args) => voidFunction to debounce
delaynumberMilliseconds to wait
options.leadingbooleanfalseFire on leading edge
ReturnsT & { cancel, flush }Debounced function with cancel and flush

Variations

Immediate mode (leading + trailing): Fire on both edges by tracking whether the trailing call should also fire. Useful for save buttons where you want instant feedback plus a final save.

// Fire on both leading and trailing edge
function useDebouncedCallback(callback, delay, { leading: true, trailing: true })

With maxWait: Guarantee the callback fires at least every maxWait ms even if input never stops. Combine debounce with a maxWait timer to cap the delay.

TypeScript Notes

  • useDebouncedValue is generic over T, so the return type matches the input type automatically.
  • useDebouncedCallback preserves the parameter types of the original callback via Parameters<T>.
  • The as const tuple pattern is not needed here since both hooks return single values or objects.

Gotchas

  • Stale closure in callback — If you inline the callback without the ref pattern, captured variables may be stale when the timer fires. Fix: The ref pattern in useDebouncedCallback always calls the latest version.
  • Delay of zerosetTimeout(fn, 0) still defers to the next tick, which may cause a visible flash. Fix: Use a guard like if (delay <= 0) return callback(...) for zero-delay cases.
  • State updates after unmount — If the component unmounts before the timer fires, React warns about leaked updates. Fix: The cleanup in useEffect handles this; make sure you do not bypass it.
  • SSR mismatchuseDebouncedValue initializes with the raw value (no setTimeout on the server), so there is no hydration mismatch. No special handling needed.

Alternatives

PackageHook NameNotes
use-debounce (npm)useDebounce, useDebouncedCallbackMost popular, supports maxWait, leading/trailing
usehooks-tsuseDebounceValue-only debounce
ahooksuseDebounce, useDebounceFnFull-featured, part of a large collection
lodash_.debounceNot a hook; wrap in useMemo or useRef
@uidotdev/usehooksuseDebounceMinimal, value-only

Real-World Example

From a production Next.js 15 / React 19 SaaS application (SystemsArchitect.io).

// Production example: Debounce pattern integrated with SWR
// File: src/hooks/use-search.ts
import { useState, useEffect, useMemo } from 'react';
import useSWR from 'swr';
 
const fetcher = (url: string) => fetch(url).then((r) => r.json());
 
export function useSearch(query: string, platform: string) {
  const [debouncedQuery, setDebouncedQuery] = useState(query);
 
  // Debounce: only update the query after 300ms of inactivity
  useEffect(() => {
    const timer = setTimeout(() => setDebouncedQuery(query), 300);
    return () => clearTimeout(timer);
  }, [query]);
 
  const shouldFetch = debouncedQuery.length >= 3;
  const apiUrl = shouldFetch
    ? `/api/search?q=${encodeURIComponent(debouncedQuery)}&platform=${platform}`
    : null;
 
  const { data, isLoading, isValidating } = useSWR(apiUrl, fetcher, {
    keepPreviousData: true,
  });
 
  return useMemo(() => ({
    results: data?.results ?? [],
    isLoading: isLoading || (shouldFetch && isValidating),
  }), [data, isLoading, shouldFetch, isValidating]);
}

What this demonstrates in production:

  • The setTimeout + clearTimeout cleanup in useEffect is the core debounce mechanism. Every time query changes, the previous timeout is cleared and a new one starts. Only when the user stops typing for 300ms does setDebouncedQuery fire.
  • This is the useDebouncedValue pattern applied directly inline. In production, this was kept inline rather than extracted to a separate hook because the debounce is tightly coupled with the SWR fetch logic and the minimum length check.
  • The cleanup function return () => clearTimeout(timer) is critical. Without it, rapid typing would schedule multiple timeouts, and stale queries would fire out of order. The cleanup ensures only the latest timeout survives.
  • SWR's null key convention (when shouldFetch is false) prevents the fetch entirely. No request is made until the debounced query reaches 3 characters.
  • keepPreviousData: true prevents a blank results flash while the new debounced query is being fetched. The previous results stay visible until fresh data arrives.

FAQs

What is the difference between useDebouncedValue and useDebouncedCallback?
  • useDebouncedValue takes a reactive value and returns a delayed copy that only updates after the delay elapses.
  • useDebouncedCallback takes a function and returns a debounced version of that function.
  • Use useDebouncedValue when you want to delay a value feeding into a useEffect. Use useDebouncedCallback when you want to debounce an action like a button click or save.
Why does useDebouncedCallback store the callback in a ref instead of using it directly?
  • Without the ref pattern, the callback captured by setTimeout would see stale closure values from the render when the timer was created.
  • The ref (callbackRef.current) is updated every render, so the timer always calls the latest version of the callback.
How do you pause or cancel a pending debounced call?

Call cancel() on the returned debounced function:

const debouncedSave = useDebouncedCallback(save, 1000);
// Later:
debouncedSave.cancel();
What does the flush method do and when would you use it?
  • flush() immediately invokes the pending callback with the last arguments and resets state.
  • Use it when the user navigates away or submits a form and you need to ensure the debounced action fires right now.
What happens if the component unmounts before the debounce timer fires?
  • The cleanup function in useEffect calls clearTimeout, canceling the pending timer.
  • This prevents "setState on unmounted component" warnings.
What does the leading option do in useDebouncedCallback?
  • When leading is true, the callback fires immediately on the first invocation.
  • Subsequent calls within the delay window are suppressed.
  • This is useful for save buttons where you want instant feedback on the first click.
Gotcha: What happens if you pass a delay of zero?
  • setTimeout(fn, 0) still defers execution to the next tick, which can cause a visible flash.
  • If you need synchronous execution with zero delay, add a guard: if (delay <= 0) return callback(...).
Gotcha: Why might the debounced callback fire with stale data if you skip the ref pattern?
  • JavaScript closures capture variables at creation time.
  • Without updating callbackRef.current on each render, the setTimeout closure would reference the callback from the render when the timer was set, not the latest one.
How does the real-world SWR example prevent fetching short queries?
  • It checks debouncedQuery.length >= 3 before constructing the API URL.
  • When the condition is false, it passes null as the SWR key, which tells SWR to skip the fetch entirely.
Why does the production example use keepPreviousData: true with SWR?
  • It prevents a blank results flash while the new debounced query is being fetched.
  • Previous results stay visible until fresh data arrives, providing a smoother user experience.
How does TypeScript infer the return type of useDebouncedValue?
  • The hook is generic over T, so the return type automatically matches the input type.
  • No explicit type annotation is needed when calling the hook.
const debounced = useDebouncedValue("hello", 300);
// debounced is inferred as string
How does useDebouncedCallback preserve the parameter types of the original function in TypeScript?
  • The generic T extends (...args: any[]) => void captures the full function signature.
  • Parameters<T> extracts the argument types so the debounced wrapper accepts the same arguments as the original callback.