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:
useDebouncedValuedelays the search query so the API is called only after the user stops typing for 300 msuseDebouncedCallbackwithleading: truefires 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
setTimeoutevery timevaluechanges. The cleanup function inuseEffectclears the previous timer, so only the last update withindelayms 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
| Parameter | Type | Default | Description |
|---|---|---|---|
value | T | — | The value to debounce |
delay | number | — | Milliseconds to wait |
| Returns | T | — | The debounced value |
useDebouncedCallback
| Parameter | Type | Default | Description |
|---|---|---|---|
callback | (...args) => void | — | Function to debounce |
delay | number | — | Milliseconds to wait |
options.leading | boolean | false | Fire on leading edge |
| Returns | T & { 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
useDebouncedValueis generic overT, so the return type matches the input type automatically.useDebouncedCallbackpreserves the parameter types of the original callback viaParameters<T>.- The
as consttuple 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
useDebouncedCallbackalways calls the latest version. - Delay of zero —
setTimeout(fn, 0)still defers to the next tick, which may cause a visible flash. Fix: Use a guard likeif (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
useEffecthandles this; make sure you do not bypass it. - SSR mismatch —
useDebouncedValueinitializes with the raw value (nosetTimeouton the server), so there is no hydration mismatch. No special handling needed.
Alternatives
| Package | Hook Name | Notes |
|---|---|---|
use-debounce (npm) | useDebounce, useDebouncedCallback | Most popular, supports maxWait, leading/trailing |
usehooks-ts | useDebounce | Value-only debounce |
ahooks | useDebounce, useDebounceFn | Full-featured, part of a large collection |
lodash | _.debounce | Not a hook; wrap in useMemo or useRef |
@uidotdev/usehooks | useDebounce | Minimal, 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+clearTimeoutcleanup inuseEffectis the core debounce mechanism. Every timequerychanges, the previous timeout is cleared and a new one starts. Only when the user stops typing for 300ms doessetDebouncedQueryfire. - This is the
useDebouncedValuepattern 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
nullkey convention (whenshouldFetchis false) prevents the fetch entirely. No request is made until the debounced query reaches 3 characters. keepPreviousData: trueprevents 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?
useDebouncedValuetakes a reactive value and returns a delayed copy that only updates after the delay elapses.useDebouncedCallbacktakes a function and returns a debounced version of that function.- Use
useDebouncedValuewhen you want to delay a value feeding into auseEffect. UseuseDebouncedCallbackwhen 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
setTimeoutwould 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
useEffectcallsclearTimeout, canceling the pending timer. - This prevents "setState on unmounted component" warnings.
What does the leading option do in useDebouncedCallback?
- When
leadingistrue, 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.currenton each render, thesetTimeoutclosure 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 >= 3before constructing the API URL. - When the condition is false, it passes
nullas 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 stringHow does useDebouncedCallback preserve the parameter types of the original function in TypeScript?
- The generic
T extends (...args: any[]) => voidcaptures the full function signature. Parameters<T>extracts the argument types so the debounced wrapper accepts the same arguments as the original callback.
Related
- useThrottle — rate-limit instead of delay
- useFetch — combine with debounced queries
- useEffect — the underlying primitive