useDebouncedValue Hook
Debounce a changing value in React without firing effects on every keystroke. Cleanup, leading vs trailing, and the stale timer that updates unmounted state.
- react
- hooks
- debounce
- interview
A search box that hits /api/search on every onChange is the classic interview miss. Five keystrokes become five in-flight requests, the slowest response can win, and your loading spinner never sits still.
useDebouncedValue is the React-shaped fix: keep the input live, delay the value you treat as committed. The hook is not a wrapper around onChange. It is a derived value that lags the source by ms of quiet time.
If you debounce the event handler instead, a controlled input either feels sticky (state updates late) or you recreate debounce(...) every render and the timer never actually coalesces. Name that tradeoff out loud.
- Debounce the value you fetch or validate on. Keep keystrokes in immediate state so the input stays responsive.
useEffectplussetTimeoutis the whole mechanism. CleanupclearTimeouton value change, delay change, and unmount.- Missing cleanup is not a leak so much as a correctness bug: every keystroke’s timer still fires, so you get a delayed update for
"r", then"re", then"rea".- Trailing (wait for quiet) is the default. Leading (fire on the first change, then ignore until quiet) is a different product.
- The hook must call
useState/useEffectunconditionally. Branch inside the effect, not around the hook. See Rules of Hooks.
Debounce the value, not the event handler, unless you mean it
Two different APIs get conflated:
| What you wrap | What the user sees | Typical use |
|---|---|---|
Value (useDebouncedValue(query, 300)) |
Input updates instantly; search/filter lags | Controlled search, autosave payload, typeahead query |
Handler (debounce(onSearch, 300)) |
The callback itself is delayed | Uncontrolled input, native listeners, non-React code |
A controlled <input value={query} onChange={...}> must set query on every keystroke. If query is the debounced value, typing feels like a laggy remote desktop. Split the states:
function SearchBox() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebouncedValue(query, 300);
useEffect(() => {
if (!debouncedQuery) return undefined;
// fetch with debouncedQuery, abort on change
}, [debouncedQuery]);
return (
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
aria-label="Search"
/>
);
}
The vanilla debounce from Debounce Implementation still matters: you would use it on a native input listener or inside a callback you do not want to re-create. In React, a value hook composes more cleanly with other effects because the delayed value is just data.
Trailing implementation
import { useEffect, useState } from 'react';
function useDebouncedValue(value, delay) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
if (delay == null || delay < 0) {
setDebounced(value);
return undefined;
}
const id = setTimeout(() => {
setDebounced(value);
}, delay);
return () => {
clearTimeout(id);
};
}, [value, delay]);
return debounced;
}
Procedure the effect follows:
- Source
valueordelaychanges (or the component mounts). - Schedule a macrotask for
delayms. The closure captures thisvalue. - If
valuechanges again before the timer fires, cleanup runs,clearTimeoutkills the pending task, and a new timer starts. - When the quiet window elapses,
setDebounced(value)commits. Downstream effects that depend ondebouncednow run once. - On unmount, cleanup runs again so a late timer cannot call
setDebouncedon a component that is gone.
That cleanup is the same contract as any subscription in useEffect Fundamentals. Strict Mode will run setup → cleanup → setup in development. If your debounce “resets” in dev, that is expected; production does not double-invoke.
Why recreating debounce() every render fails
// Broken: new closure, new timerId every render
const onSearch = debounce((q) => fetch(`/api?q=${q}`), 300);
return <input onChange={(e) => onSearch(e.target.value)} />;
Each render allocates a new debounced function with its own timerId. The previous function’s timer still fires. You get both “too many calls” and “calls after unmount.” Stabilize with useMemo/useRef, or skip the wrapper and debounce the value.
Leading vs trailing for a value
Trailing is “wait until they stop typing.” Leading is “react immediately, then hold.”
function useDebouncedValue(value, delay, { leading = false } = {}) {
const [debounced, setDebounced] = useState(value);
const waiting = useRef(false);
useEffect(() => {
if (delay == null || delay < 0) {
setDebounced(value);
return undefined;
}
if (leading && !waiting.current) {
setDebounced(value);
waiting.current = true;
}
const id = setTimeout(() => {
waiting.current = false;
setDebounced(value);
}, delay);
return () => clearTimeout(id);
}, [value, delay, leading]);
return debounced;
}
With leading: true, the first change after a quiet period updates immediately, then further changes wait for the window. The trailing setDebounced(value) at the end of the window still runs so the last keystroke is not dropped. If you want leading only (ignore the tail), skip that last write when leading is true and waiting was already set. Interviews rarely need both edges; they do want you to name which edge you implemented.
Throttle is the other tool: sample a stream on a cadence while events continue (Throttle Implementation). Resize and scroll usually throttle. Search usually debounces.
Footguns
Stale timer after unmount. Navigate away 100 ms after the last key. Without clearTimeout, the callback still runs. React 18+ is quiet about setState on unmounted components, but the callback can still touch stores, analytics, or a parent that is alive. Cleanup is not optional.
delay in the dependency array. Changing delay must restart the timer. Omitting it means you keep the old wait. Including a new object for “options” every render restarts the timer every time, which is the same bug as an unstable callback dep.
Debounced value on first render. useState(value) means the first paint matches the source. There is no initial 300 ms wait. If you need “empty until the first quiet window,” initialize to undefined or '' and accept a first-render mismatch with the input.
Object identities. Debouncing an object that is a new literal every render ({ q: text }) never goes quiet. Debounce a primitive, or memoize the source.
SSR. setTimeout does not run on the server. The server render and the client’s first render both see the raw value. That is usually what you want for an input.
Interview out-loud answer
I would not debounce the controlled input’s
onChange. I keep immediate state for the field and deriveuseDebouncedValue(value, ms)with an effect that sets a timeout and clears it on change or unmount. Missing cleanup means every intermediate value still flushes later, which is the opposite of debounce. Trailing waits for quiet; leading fires on the first change in a window. The vanilla debounce helper is for handlers and native listeners; the hook is for data that other effects can depend on. I still follow the rules of hooks: the hook always runs, and I only branch inside the effect.
Further reading
Related on this site
- Custom Hooks Design: when to extract a
use*function - Rules of Hooks: call order, no conditional hooks
- useEffect Fundamentals: cleanup is part of the effect
- Debounce Implementation: trailing, leading,
cancel - Implement a Fake setTimeout: test the quiet window without a real clock
- Clear All Timeouts: owning timer ids as a set
- The Event Loop: timers are macrotasks
- useFetch With AbortController: pair the delayed query with an abortable request
Related guides
- useCopyToClipboard HookCopy text with navigator.clipboard, fallback execCommand, transient copied state, and permissions that fail in insecure contexts.
- useEventListener HookAttach a window, document, or element listener with a stable handler ref so you do not rebind on every render. Capture option and target changes.
- useIdle HookDetect idle users: reset a timer on input events, fire idle after timeout, and decide whether mount starts the timer immediately.
- useInfiniteScroll HookLoad the next page when a sentinel enters the viewport. IntersectionObserver, hasMore, in-flight guards, and the footer that never intersects.
- useInterval HookA setInterval that sees the latest callback without restarting the timer when the callback identity changes. Dan Abramov’s ref pattern, named.