Cristian Gatu

Search or run a command...

Search for a page, or run a command

Back to articles

How to implement debounce the easy way

September 23, 2026 · 2 min read

Debounce shows up in the same few places over and over: a search box that shouldn't fire a request on every keystroke, a resize handler that shouldn't run on every pixel, a save-as-you-type form that shouldn't hit the network every character. The concept is simple — wait for a pause before doing the thing — and the implementation is simpler than most people expect.

The plain-JavaScript version

Forget React for a second. A debounce is a function that wraps another function and delays it, resetting the delay every time it's called again before it fires.

function debounce<Args extends unknown[]>(
  fn: (...args: Args) => void,
  delayMs: number
) {
  let timeoutId: ReturnType<typeof setTimeout>;

  return (...args: Args) => {
    clearTimeout(timeoutId);
    timeoutId = setTimeout(() => fn(...args), delayMs);
  };
}

That's the whole thing. Every call cancels the pending timeout and schedules a new one; the wrapped function only runs once the calls stop coming for delayMs.

Wrapping it for React: a debounced value

In a component, what you usually want isn't a debounced function — it's a debounced value, so the rest of your component can just read it like normal state.

function useDebouncedValue<T>(value: T, delayMs: number): T {
  const [debounced, setDebounced] = useState(value);

  useEffect(() => {
    const timeoutId = setTimeout(() => setDebounced(value), delayMs);
    return () => clearTimeout(timeoutId);
  }, [value, delayMs]);

  return debounced;
}

Usage is a single line:

const [query, setQuery] = useState("");
const debouncedQuery = useDebouncedValue(query, 300);

useEffect(() => {
  if (!debouncedQuery) return;
  fetchResults(debouncedQuery);
}, [debouncedQuery]);

The input updates immediately — no lag while typing — and debouncedQuery only catches up 300ms after you stop. The cleanup function is what makes this correct: every keystroke clears the previous timeout before scheduling a new one, so only the last one in a burst ever fires.

When you'd want more than this

This covers the large majority of real cases. Reach for a library (use-debounce, lodash.debounce) when you need things this hook doesn't do out of the box: a leading-edge call (fire immediately, then ignore the burst), a maxWait ceiling so a value that never stops changing still updates occasionally, or debouncing a callback ref instead of a value. Until you actually need one of those, the ten lines above are one less dependency to install and one less API to learn.