Implement a custom useDebounce hook from scratch
Two flavors: `useDebouncedValue(value, delay)` returns the latest value after the input has been stable for `delay` ms — built with `useState` + `useEffect` setTimeout cleanup. `useDebouncedCallback(fn, delay)` returns a stable function that delays its invocation — built with `useRef` for the timer and `useRef` for the latest fn so closures stay fresh.
Two hooks people conflate into one, but they solve different problems.
1. useDebouncedValue — the value lags behind state.
function useDebouncedValue<T>(value: T, delay = 300): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const t = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(t);
}, [value, delay]);
return debounced;
}The cleanup is what makes this work — every new value cancels the previous timer. Use when you want a stable value you can put in another effect's deps (e.g., trigger a search).
2. useDebouncedCallback — the function fires later.
function useDebouncedCallback<T extends (...args: any[]) => void>(fn: T, delay = 300) {
const timer = useRef<ReturnType<typeof setTimeout>>();
const fnRef = useRef(fn);
useEffect(() => { fnRef.current = fn; }, [fn]); // keep latest
return useCallback((...args: Parameters<T>) => {
clearTimeout(timer.current);
timer.current = setTimeout(() => fnRef.current(...args), delay);
}, [delay]);
}Two refs is the trick: timer survives renders without triggering them; fnRef is updated each render so the latest closure (with current props/state) fires, not the stale one from when the hook was created.
The bug people write first.
// Wrong — new debounced fn every render, timer resets nothing.
const handle = debounce((q) => fetch(q), 300);Each render makes a fresh debounce, with its own private timer. Typing reac produces four separate debounced fns with four separate timers. Wrap it in useMemo or useRef, or — better — use the hook above.
Cleanup on unmount. Add this to useDebouncedCallback for safety:
useEffect(() => () => clearTimeout(timer.current), []);Without it, a debounced effect can fire after the component unmounts — possible state update warning, possible memory leak holding onto closure refs.
Flush and cancel. Production libraries (use-debounce, lodash) expose .flush() (fire pending now) and .cancel(). Add them by exposing imperative methods alongside the returned function:
const debounced = useDebouncedCallback(save, 500);
// expose: debounced.flush(), debounced.cancel()When to prefer React's built-in alternatives.
useDeferredValue(value)— defer rendering of expensive trees without an arbitrary delay; uses transition priority. Best for keeping inputs responsive while a heavy list re-renders.useTransition— for navigation / state updates you want to mark as low-priority.
These don't replace debouncing the network call; they replace debouncing the render.
Follow-up questions
- •Why does the naive `debounce(fn)` inside a component fail?
- •When would you choose useDeferredValue over useDebouncedValue?
- •How would you add flush/cancel methods?
- •What happens if delay changes while a timer is pending?
Common mistakes
- •Calling the imported `debounce` directly inside the component body each render.
- •Forgetting to clean up the timer on unmount.
- •Not refreshing the callback ref, so the debounced fn calls stale state.
- •Putting the debounced function in a useEffect dep array (it changes each render).
Performance considerations
- •useDebouncedValue causes one extra render after the delay; useDebouncedCallback causes none.
- •useDeferredValue is preferable when the cost is render, not network.
Edge cases
- •Delay changes mid-pending — most implementations restart with the new delay on next call.
- •Burst of identical values — value path collapses, callback path still resets each time.
Real-world examples
- •Typeahead search, autosave on form change, window resize handlers.