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();
}}
/>
);
}
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]);
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);
}, []);
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]);
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;
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.currentwhen 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
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 };
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;
}
Import it with your project's alias. Swap @/ for whatever alias you use.
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
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);
}}
/>
);
}
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);
}}
/>
);
}
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]);
}
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>;
}
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
The wrapper is the address.
useCallbackRefreturns a function created withuseMemo(..., []). That function closes over the ref object, not overcallback. The inner call iscallbackRef.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, thoughuseMemois documented as a performance optimization rather than a semantic guarantee.useDebouncedCallbackthen wraps the scheduler inuseCallbackwith[handleCallback, delay]. BecausehandleCallbackis stable, changing the callback does not changesetValue. Changingdelaydoes, which is why the delay should be a constant or a memoized value.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.The timer is a single slot.
debounceTimerRefstarts at0. 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 whendelayorcallbackchanges. A pending timer keeps its original delay until it fires. There is no publiccancelorflush.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.The timer is browser code.
window.setTimeoutandwindow.clearTimeoutare 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 touchingwindow. If you need the hook in Node or React Native, replacewindow.setTimeoutwith the global timer and useReturnType<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)