Back to React
React
easy
mid

How would you 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.

5 min read·~15 min to think through

Two hooks people conflate into one, but they solve different problems.

1. useDebouncedValue — the value lags behind state.

tsx
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.

tsx
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.

tsx
// 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:

tsx
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:

tsx
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.

Related questions