Write a React `useDebouncedValue(value, delay)` hook using useState and useEffect. Then explain why a `useDebouncedCallback(fn, delay)` variant needs a ref holding the latest `fn`.
answer
- cleanup cancels the previous timer
- effect lifecycle does the work for you
- a stable wrapper is the whole point
- stable means frozen closure
- read at call time, not at creation time
basics
~20 suseDebouncedValue keeps a state copy updated by an effect that starts a timeout and clears it in cleanup, so each new value restarts the delay. A callback version must keep the newest fn in a ref, because a debounced wrapper created once would otherwise fire a frozen closure.
solid answer
~50 sFor the value version, hold `debounced` in state and run an effect on `[value, delay]` that does `const id = setTimeout(() => setDebounced(value), delay)` and returns `() => clearTimeout(id)`. Cleanup runs before the next effect and on unmount, so a fast-changing value keeps cancelling and restarting the timer, and nothing fires after the component is gone. The callback version is harder because the debounced wrapper must survive across renders — if you recreate it each render, every keystroke gets a new timer and nothing is ever debounced. So you create the wrapper once, and it can no longer close over the current `fn`: the `fn` it captured belongs to the render that created it, and will read stale props and state when it eventually fires. The fix is a ref updated on every render (`fnRef.current = fn`) that the wrapper reads at call time, so the timer stays stable while the behaviour stays current.
code
javascript · 26 linesimport { useState, useRef, useEffect, useCallback } from 'react';
export function useDebouncedValue(value, delay) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id);
}, [value, delay]);
return debounced;
}
export function useDebouncedCallback(fn, delay) {
const fnRef = useRef(fn);
const timerRef = useRef(null);
useEffect(() => {
fnRef.current = fn;
});
useEffect(() => () => clearTimeout(timerRef.current), []);
return useCallback(
(...args) => {
clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => fnRef.current(...args), delay);
},
[delay],
);
}go deeper
Be able to write the value version and say out loud that the effect's cleanup clears the previous timeout, which is what makes rapid changes collapse into one update. Know that setTimeout must be cleared on unmount.
Explain the effect lifecycle producing the debounce, and articulate the two competing constraints in the callback version: the wrapper must be stable for debouncing to work, and stability is exactly what makes its captured fn go stale.
Show the latest-ref pattern as a general technique — anything read at call time rather than depended on goes in a ref refreshed after commit — and be ready to argue debounce versus useDeferredValue versus throttle in terms of where the cost actually is.
Decide the policy rather than the snippet: whether debouncing belongs in components at all or behind the data layer, what the delay budget should be across the product, and how to keep a fleet of hand-rolled timing hooks from drifting into inconsistent, untestable behaviour.
## The value version ```js import { useState, useEffect } from 'react'; function useDebouncedValue(value, delay) { const [debounced, setDebounced] = useState(value); useEffect(() => { const id = setTimeout(() => setDebounced(value), delay); return () => clearTimeout(id); }, [value, delay]); return debounced; } ``` The elegance is that the debouncing falls out of the effect lifecycle rather than being coded. Every time `value` changes, React runs the cleanup of the previous effect — cancelling the pending timer — and then runs the new effect, starting a fresh one. Type six characters quickly and you get six starts and five cancellations; only the last timer survives to fire. Note that the timer id is an ordinary local `const`: the cleanup closure captures it, so no ref is needed here at all. Claiming otherwise is a common over-engineering tell. Cleanup also fires on unmount, which matters: without it the callback would run on a component that no longer exists, and in a data-fetching context that is how you get a request fired for a screen the user has already left. The hook returns `value` immediately on the first render (state is initialised from it), which is normally what you want — the delay applies to *changes*, not to the initial paint. ## When to use the value version A debounced value is the right shape when the consumer is declarative: a search box where you render results for `useDebouncedValue(query, 300)` while the input itself stays fully responsive on the undebounced `query`. It is worth naming the alternative here, because interviewers probe it: `useDeferredValue` solves the adjacent problem of keeping the UI responsive under heavy rendering by letting React render the stale value at a lower priority, and it adapts to how fast the device actually is. A debounce is a fixed wall-clock delay — the right tool when the cost is *downstream* (a network request, an expensive external call), not when the cost is React rendering. ## Why the callback version is different ```js import { useRef, useEffect, useCallback } from 'react'; function useDebouncedCallback(fn, delay) { const fnRef = useRef(fn); const timerRef = useRef(null); useEffect(() => { fnRef.current = fn; }); useEffect(() => () => clearTimeout(timerRef.current), []); return useCallback((...args) => { clearTimeout(timerRef.current); timerRef.current = setTimeout(() => fnRef.current(...args), delay); }, [delay]); } ``` Two constraints collide here. **The wrapper must be stable.** Debouncing works by a single pending timer being repeatedly cancelled. If a new wrapper with its own fresh timer variable is produced on each render, each call starts an independent timer and every one of them eventually fires — the opposite of debouncing. So the wrapper is memoized, and the timer id is held in a ref precisely because it must outlive the individual call and be reachable from the next one. **But a stable wrapper freezes its closure.** `useCallback(..., [delay])` means the function is created once and keeps the `fn` from that render. If `fn` is `() => save(draft)`, the wrapper will still be saving the first `draft` an hour later. This is the stale-closure trap, and the standard cure is the latest-ref pattern: keep the changing thing in a ref that is refreshed after every commit, and read `fnRef.current` at *call* time rather than capturing `fn` at *creation* time. Because refs are mutable boxes that do not participate in rendering, updating one does not invalidate the memoized wrapper. The write to `fnRef.current` goes in an effect rather than the body for the same purity reason that applies to any render-time mutation: a render can be abandoned or, in StrictMode, double-invoked. (React has an in-progress `useEffectEvent` hook intended to make this pattern a first-class API; until it is stable in your React version, the latest-ref pattern is the portable way to express "read this at call time, do not depend on it".) ## Unmount and leading edge The second effect returns a cleanup that clears any pending timer on unmount. Without it a debounced save fires after the user navigates away, touching state on a dead component and confusing the user about what got persisted. A leading-edge variant fires immediately on the first call and then suppresses for the delay window; it is the right choice for actions that must feel instant, like a button, while trailing-edge debounce is right for text input. Throttling is the neighbouring idea — trailing debounce fires once after the storm ends, whereas throttling fires at most once per interval *during* the storm. Choosing wrong is a real bug: a scroll handler that only reports after the user stops scrolling is useless for a progress indicator. ## What interviewers listen for That cleanup is what makes debouncing work rather than an afterthought; that the value version needs no refs at all; that the callback version needs exactly two refs and why each one exists; and that a timer must be cancelled on unmount.
- Would `useMemo` be a correct way to create the debounced wrapper instead of `useCallback`?It works mechanically — a memoized factory returns a stable function — but it inherits the same stale-`fn` problem and adds a subtler one: useMemo is a performance hint React is allowed to discard, so a dropped cache would produce a new wrapper and orphan the pending timer. Keeping the timer in a ref rather than inside the memoized closure is what makes either version safe.
- When would you reach for `useDeferredValue` rather than a debounce?When the cost you are avoiding is React rendering a large tree, not an external call. `useDeferredValue` lets React render the previous value at low priority and interrupt that work when new input arrives, so it adapts to the device instead of imposing a fixed wait. A debounce is right when each update triggers something genuinely expensive off-screen, like a network request.
- Why does the value version need no ref for the timer id when the callback version does?Because in the value version each effect run owns exactly one timer, and its cleanup closure captures that local id directly — the id never has to be seen by any other invocation. In the callback version the timer is created by one call and must be cancelled by a later, independent call, so it needs storage that outlives a single invocation.
- What breaks if the debounced callback is not cancelled on unmount?The timer still fires and calls a function closed over a component that no longer exists — typically a state setter that now does nothing, or worse, a save or navigation the user did not expect after leaving the screen. Returning a cleanup that clears the pending timer from a mount-scoped effect is the whole fix.
saying these in an interview costs you the question
- Says the timer id must live in a ref even in the value version
- Recreates the debounced wrapper every render and expects debouncing
- Omits clearTimeout in cleanup and calls it 'just a small leak'
- Confuses debounce with throttle when describing scroll handling
- Puts fnRef.current = fn in the component body and calls it safe