Search across all documentation pages
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
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.
"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 msuseDebouncedCallback with leading: true fires immediately on first click, then ignores rapid re-clicks for 1 secondsetTimeout 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.cancel() clears any pending invocation. flush() invokes the pending callback immediately and resets state.| Parameter | Type | Default | Description |
|---|---|---|---|
value | T | - | The value to debounce |
delay | number | - | Milliseconds to wait |
| Returns | T | - | The debounced value |
| 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 |
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.
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>.as const tuple pattern is not needed here since both hooks return single values or objects.useDebouncedCallback always calls the latest version.setTimeout(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.useEffect handles this; make sure you do not bypass it.useDebouncedValue initializes with the raw value (no setTimeout on the server), so there is no hydration mismatch. No special handling needed.| 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 |
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:
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.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.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.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.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.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.setTimeout would see stale closure values from the render when the timer was created.callbackRef.current) is updated every render, so the timer always calls the latest version of the callback.Call cancel() on the returned debounced function:
const debouncedSave = useDebouncedCallback(save, 1000);
// Later:
debouncedSave.cancel();flush() immediately invokes the pending callback with the last arguments and resets state.useEffect calls clearTimeout, canceling the pending timer.leading is true, the callback fires immediately on the first invocation.setTimeout(fn, 0) still defers execution to the next tick, which can cause a visible flash.if (delay <= 0) return callback(...).callbackRef.current on each render, the setTimeout closure would reference the callback from the render when the timer was set, not the latest one.debouncedQuery.length >= 3 before constructing the API URL.null as the SWR key, which tells SWR to skip the fetch entirely.T, so the return type automatically matches the input type.const debounced = useDebouncedValue("hello", 300);
// debounced is inferred as stringT 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.Reviewed by Chris St. John·Last updated Jul 10, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥