useDebounce hooks for React
Wait until the user stops typing before firing a search or autosave. Includes a value version for derived state and a callback version for event handlers.
import { useCallback, useEffect, useRef, useState } from "react";
/** Returns `value` only after it has stopped changing for `delayMs`. */
export function useDebouncedValue<T>(value: T, delayMs = 300): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delayMs);
return () => clearTimeout(timer);
}, [value, delayMs]);
return debounced;
}
/** Returns a stable function that runs `fn` once calls stop for `delayMs`. */
export function useDebouncedCallback<A extends unknown[]>(
fn: (...args: A) => void,
delayMs = 300,
) {
const fnRef = useRef(fn);
const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
// Always call the latest fn without resetting the timer on every render
useEffect(() => {
fnRef.current = fn;
}, [fn]);
// Don't fire after the component unmounts
useEffect(() => () => clearTimeout(timerRef.current), []);
return useCallback(
(...args: A) => {
clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => fnRef.current(...args), delayMs);
},
[delayMs],
);
}Usage
"use client";
import { useEffect, useState } from "react";
import { useDebouncedValue } from "@/hooks/use-debounce";
export function ProductSearch() {
const [query, setQuery] = useState("");
const [results, setResults] = useState<{ id: string; name: string }[]>([]);
const debouncedQuery = useDebouncedValue(query, 250);
useEffect(() => {
if (!debouncedQuery) return setResults([]);
const controller = new AbortController();
fetch(`/api/products?q=${encodeURIComponent(debouncedQuery)}`, { signal: controller.signal })
.then((res) => res.json())
.then(setResults)
.catch(() => {});
return () => controller.abort(); // ignore responses for stale queries
}, [debouncedQuery]);
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search" />
<ul>{results.map((p) => <li key={p.id}>{p.name}</li>)}</ul>
</>
);
}How it works & gotchas
- 01Debounce the request, not the input. The text field should stay controlled and instant; only the expensive work waits.
- 02Pair debouncing with
AbortController. A slow response for an old query can otherwise arrive after a newer one and overwrite it. - 03The callback version keeps the latest
fnin a ref, so passing an inline arrow function doesn't restart the timer on every render. - 04For scroll or resize handlers that should fire steadily while active, you want throttling, not debouncing.
Need this wired into a real system?
We build and harden production software end to end.
More code helpers
SSR-safe useLocalStorage hook
Persist small bits of UI state in localStorage without hydration mismatches in Next.js. Built on useSyncExternalStore, so it also stays in sync across components and browser tabs.
fetch with timeout, cancellation and typed errors
fetch has no default timeout and doesn't throw on 4xx/5xx. This small wrapper adds both, and still lets callers cancel with their own AbortSignal.
Stream LLM responses from a Next.js route handler
Send tokens to the browser as the model produces them instead of making users wait for the full completion. Uses a plain ReadableStream, so it works with any client that can read a fetch body.