DEV Community

Cover image for useDebouncedCallback: Debounce Without the Stale Closure
Saiful Islam
Saiful Islam

Posted on

useDebouncedCallback: Debounce Without the Stale Closure

A small hook that stops a delayed callback from firing with yesterday's state — and keeps the timer's identity stable even when the callback changes on every render.

The Problem

Here is a search box that looks right at a glance. Every keystroke updates the input and schedules a request. The previous timer is cleared, so only the last keystroke should fetch.

function SearchBox() {
  const [query, setQuery] = useState("");
  const [results, setResults] = useState<string[]>([]);

  const scheduleSearch = useMemo(() => {
    let timer = 0;
    return () => {
      window.clearTimeout(timer);
      timer = window.setTimeout(() => {
        fetch(`/api/search?q=${query}`)
          .then((response) => response.json())
          .then(setResults);
      }, 300);
    };
  }, []);

  return (
    <input
      onChange={(event) => {
        setQuery(event.target.value);
        scheduleSearch();
      }}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

The input is live. The data is frozen.

The URL is built from query captured when the memoized function was created. That was the first render, where query was "". Type react, wait 300 ms, and you get the results for an empty search. The component has a stale closure, and the empty dependency array is the cause.

The first fix is to add query to the dependency array:

// before
}, []);
// after
}, [query]);
Enter fullscreen mode Exit fullscreen mode

Now every keystroke creates a new scheduler. The old timer is not cleared by the new one, because the timer variable lives inside the old closure. A fast typist gets one pending timer per keystroke, each with its own query. If scheduleSearch is passed to a child or used in an effect, the identity churn also causes re-subscriptions and defeats memoization.

The second fix is to keep the scheduler in a ref and read the latest query from another ref:

const queryRef = useRef(query);
queryRef.current = query;

const scheduleSearch = useCallback(() => {
  window.setTimeout(() => {
    void fetch(`/api/search?q=${queryRef.current}`);
  }, 300);
}, []);
Enter fullscreen mode Exit fullscreen mode

This works in a simple app, but writing a ref during render is not render-pure. React can start a render, let a higher-priority update interrupt it, and throw the first render away. The ref now holds a value from a render that never committed. In concurrent React, that is a correctness bug, not a style nit.

The third fix is to put the callback in useCallback and accept the changing identity:

const scheduleSearch = useCallback(() => {
  const id = window.setTimeout(() => {
    void fetch(`/api/search?q=${query}`);
  }, 300);
  return () => window.clearTimeout(id);
}, [query]);
Enter fullscreen mode Exit fullscreen mode

The timer is fresh, but the function is new on every keystroke. If an effect depends on it, the effect tears down and re-registers each time. If it is a prop to a memoized child, the child re-renders anyway. The debounce survives; the stable identity you needed does not.

You need one function to do two jobs: keep a stable identity for the timer and every effect that depends on it, yet read the newest state when the timer finally fires.

The hook below splits those jobs. The scheduler is stable. The callback is read through a ref at fire time.

const handleCallback = useCallbackRef(callback);
const debounceTimerRef = useRef(0);

useEffect(() => () => window.clearTimeout(debounceTimerRef.current), []);

const setValue = useCallback(
  (...args: Parameters<T>) => {
    window.clearTimeout(debounceTimerRef.current);
    debounceTimerRef.current = window.setTimeout(
      () => handleCallback(...args),
      delay
    );
  },
  [handleCallback, delay]
);

return setValue;
Enter fullscreen mode Exit fullscreen mode

The Mental Model

Think of a mailbox with a fixed address and a replaceable letter.

The mailbox is the function you get back. Its address never changes, so the timer, effects, and children always know where to send work. The letter inside is the callback. After every committed render, a new letter replaces the old one. When the timer finally rings, the carrier opens the box and reads whatever letter is there.

  • Identity: the returned scheduler is the mailbox. It closes over a stable ref object, not over the callback value, so its identity survives callback changes.
  • Update timing: the letter is replaced in a passive effect after commit. A render that never commits never becomes the letter.
  • Read timing: the wrapper reads callbackRef.current when the timer fires, not when the timer is scheduled. That is the difference between fresh and stale.

Two consequences follow. An inline callback no longer resets the timer, because the scheduler's identity does not change. And a pending timer can call the newest callback with arguments from the call that scheduled it, so pass the exact value you want if that distinction matters.

The mailbox never moves; only the letter inside changes.

How to Use It

The hook has one helper. Put both files next to each other.

src/
└── hooks/
    ├── use-callback-ref.ts
    └── use-debounced-callback.ts
Enter fullscreen mode Exit fullscreen mode

src/hooks/use-callback-ref.ts:

import { useEffect, useMemo, useRef } from "react";

function useCallbackRef<T extends (...args: never[]) => unknown>(
  callback: T | undefined
): T {
  const callbackRef = useRef(callback);

  useEffect(() => {
    callbackRef.current = callback;
  });

  return useMemo(() => ((...args) => callbackRef.current?.(...args)) as T, []);
}

export { useCallbackRef };
Enter fullscreen mode Exit fullscreen mode

src/hooks/use-debounced-callback.ts:

import { useCallback, useEffect, useRef } from "react";

import { useCallbackRef } from "@/hooks/use-callback-ref";

export function useDebouncedCallback<T extends (...args: never[]) => unknown>(
  callback: T,
  delay: number
) {
  const handleCallback = useCallbackRef(callback);
  const debounceTimerRef = useRef(0);

  useEffect(() => () => window.clearTimeout(debounceTimerRef.current), []);

  const setValue = useCallback(
    (...args: Parameters<T>) => {
      window.clearTimeout(debounceTimerRef.current);
      debounceTimerRef.current = window.setTimeout(
        () => handleCallback(...args),
        delay
      );
    },
    [handleCallback, delay]
  );

  return setValue;
}
Enter fullscreen mode Exit fullscreen mode

Import it with your project's alias. Swap @/ for whatever alias you use.

import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
Enter fullscreen mode Exit fullscreen mode

Search as the user types

import { useState } from "react";

import { useDebouncedCallback } from "@/hooks/use-debounced-callback";

export function SearchBox() {
  const [query, setQuery] = useState("");
  const [results, setResults] = useState<string[]>([]);

  const search = useDebouncedCallback(async (value: string) => {
    const response = await fetch(`/api/search?q=${encodeURIComponent(value)}`);
    setResults((await response.json()) as string[]);
  }, 300);

  return (
    <input
      value={query}
      onChange={(event) => {
        setQuery(event.target.value);
        search(event.target.value);
      }}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

The callback can be async, and the input updates immediately while the request waits. The scheduler stays stable across renders, so a new function identity does not restart the timer. Debouncing the request does not cancel requests already in flight; add AbortController if out-of-order responses matter.

Save a draft after the user pauses

import { useState } from "react";

import { useDebouncedCallback } from "@/hooks/use-debounced-callback";

type Draft = { title: string; body: string };

export function DraftEditor() {
  const [draft, setDraft] = useState<Draft>({ title: "", body: "" });
  const save = useDebouncedCallback((nextDraft: Draft) => {
    void fetch("/api/draft", {
      method: "PUT",
      body: JSON.stringify(nextDraft),
    });
  }, 800);

  return (
    <textarea
      value={draft.body}
      onChange={(event) => {
        const next = { ...draft, body: event.target.value };
        setDraft(next);
        save(next);
      }}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

Passing the next draft as an argument makes the delayed write carry the snapshot from the keystroke that scheduled it, while the callback itself can still see the latest render's state.

Keep a listener subscribed once

import { useEffect } from "react";

import { useDebouncedCallback } from "@/hooks/use-debounced-callback";

type Point = { x: number; y: number };

export function useScrollReport() {
  const report = useDebouncedCallback((point: Point) => {
    void fetch("/api/scroll", {
      method: "POST",
      body: JSON.stringify(point),
    });
  }, 250);

  useEffect(() => {
    const onScroll = () => report({ x: window.scrollX, y: window.scrollY });
    window.addEventListener("scroll", onScroll);
    return () => window.removeEventListener("scroll", onScroll);
  }, [report]);
}
Enter fullscreen mode Exit fullscreen mode

The effect runs once because report does not change when the callback body changes. That is the payoff: a stable dependency that still calls the latest code.

Anti-pattern

❌ Do not call the debounced function during render.

import { useDebouncedCallback } from "@/hooks/use-debounced-callback";

function SearchBox({ query }: { query: string }) {
  const run = useDebouncedCallback((value: string) => {
    void fetch(`/api/search?q=${value}`);
  }, 300);

  run(query);
  return <p>Searching for {query}</p>;
}
Enter fullscreen mode Exit fullscreen mode

This schedules a timer on every render, including renders caused by unrelated state, and it makes render impure. Call it from the event that actually happened.

When to Use It

Reach for this hook only when all three statements are true:

  • You need a delayed call from a rapid event, and only the final call's arguments should win.
  • The callback can change between renders because it closes over state or props, and it should use the newest committed version.
  • You need a stable function identity for a dependency array, a memoized child, or a long-lived subscription.
Situation Better tool
You need to cancel or flush the pending call A debounce utility with cancel() and flush()
Every call matters and must run in order A queue or throttle
You are debouncing a value, not an event useDeferredValue, or derive the value first
The callback must run during server rendering or in Node A server-side scheduler with the correct timer type
You need to cancel in-flight network requests AbortController in addition to debouncing
You need the pending call to run on unmount Explicit cleanup or a library with flush()

Smell test: if you cannot name the event that starts the timer and the render value that must stay fresh, you probably want a plain setTimeout or a debounce library, not this hook.

How It Works Under the Hood

  1. The wrapper is the address. useCallbackRef returns a function created with useMemo(..., []). That function closes over the ref object, not over callback. The inner call is callbackRef.current?.(...args), so the identity stays fixed while the value behind it changes. In current React the memoized wrapper is retained for the component's lifetime, though useMemo is documented as a performance optimization rather than a semantic guarantee. useDebouncedCallback then wraps the scheduler in useCallback with [handleCallback, delay]. Because handleCallback is stable, changing the callback does not change setValue. Changing delay does, which is why the delay should be a constant or a memoized value.

  2. The ref is updated after commit, not during render. The helper uses useEffect(() => { callbackRef.current = callback; }); with no dependency array. It runs after every committed render. This is deliberate. A render can be discarded before it commits; writing the callback ref during render would let a discarded render leak its callback into the live tree. The effect only exposes callbacks from committed renders. The read is deferred until fire time, which is why the timer never closes over the old callback.

  3. The timer is a single slot. debounceTimerRef starts at 0. Every call clears the previous timeout and stores the new id. The unmount effect returns a cleanup that clears whatever id is current. Only the last call before the delay wins; earlier arguments are dropped. The cost is one timeout and one retained argument list per pending call. The cleanup happens on unmount, but not when delay or callback changes. A pending timer keeps its original delay until it fires. There is no public cancel or flush.

  4. Effect ordering creates a narrow edge. The callback ref is updated in a passive effect after commit. A timer that fires between commit and that effect would see the previous committed callback. For a normal debounce delay this window is effectively invisible, but it is not zero. In development, StrictMode runs effect setup, cleanup, and setup again on mount; the initial cleanup clears timer id 0, which is harmless unless you scheduled a timer from a layout effect in that gap.

  5. The timer is browser code. window.setTimeout and window.clearTimeout are referenced only when the returned function is called or when cleanup runs. During server rendering, effects do not run and the returned function should not be called, so the component renders without touching window. If you need the hook in Node or React Native, replace window.setTimeout with the global timer and use ReturnType<typeof setTimeout> for the ref type; do not call the returned function during SSR.

Here is the value at each phase. S1 is the scheduler identity; it stays the same as long as delay does.

Phase callbackRef.current setValue Timer
Initial render with callback A A S1 none
Commit and passive effect A S1 none
Discarded concurrent render with callback B A S1 none
New committed render with callback C A S1 none
Passive effect after that commit C S1 none
First setValue("re") C S1 T1
Next render with inline callback D C S1 T1
Passive effect after that commit D S1 T1
T1 fires D S1 fired

The table shows the split: the callback slot moves, the scheduler does not. A discarded render never reaches the slot. A timer scheduled before a render can fire with the callback from before or after that render, depending on whether the passive effect has run.

useEffectEvent, where your React version provides it, solves a related problem: an effect that needs the latest props or state without re-running. It is not a general-purpose debounce scheduler. Its function is meant to be called from effects, not passed to children or stored as an event handler, and it does not manage timer identity or cleanup. This hook solves a broader, older problem: a stable function you can hand to an event handler, a subscription, or a child, with the latest callback read when the timer fires.

Conclusion

The tradeoff is small and deliberate: one stable scheduler, one mutable callback slot, one timer. You give up cancel, flush, and automatic rescheduling when the delay changes. In return, you get a function that can sit in dependency arrays without restarting the timer, while still calling the newest committed callback.

  • Stable identity is not a frozen closure. The wrapper is stable; the callback is read at fire time.
  • Update the callback after commit, never during render. A discarded render must not leak into the live tree.
  • The pending timer is cleared on unmount, not on every change. Keep the delay stable and pass the exact arguments you want the delayed call to receive.

Use it when a debounced event handler must survive re-renders. Reach for a real debounce utility when you need cancel, flush, or a timer that reschedules when the delay itself changes.

Top comments (0)