Back to blogs

Debouncing in React: A Practical Guide

February 22, 20265 min readby Ritik Gupta
#React#Performance#Frontend

Debouncing in React

If you've ever built a search input that fires an API call on every keystroke, you already understand why debouncing exists. Let's break down what it is and how to do it right in React.

What is Debouncing?

Debouncing is a technique that delays the execution of a function until after a specified period of inactivity. Instead of calling your function on every event, you wait until the user stops triggering the event for a set amount of time.

Think of it like an elevator door — it doesn't close immediately every time someone steps in. It waits a moment to see if anyone else is coming.


The Problem Without Debouncing

// ❌ This fires an API call on EVERY single keystroke
function SearchBar() {
  const handleChange = (e) => {
    fetchResults(e.target.value); // called 20+ times while typing "react hooks"
  };

  return <input onChange={handleChange} placeholder="Search..." />;
}

If a user types "react hooks" (11 characters), that's 11 API calls — most of them wasted.


Building a useDebounce Hook

The cleanest approach is a reusable custom hook. Here's one that's production-ready:

// hooks/useDebounce.ts
import { useState, useEffect } from "react";

function useDebounce<T>(value: T, delay: number = 500): T {
  const [debouncedValue, setDebouncedValue] = useState<T>(value);

  useEffect(() => {
    const timer = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    // Cleanup: cancel the timer if value changes before delay expires
    return () => clearTimeout(timer);
  }, [value, delay]);

  return debouncedValue;
}

export default useDebounce;

The key insight is the cleanup function returned from useEffect. Every time value changes, the previous timer is cancelled and a fresh one starts. The debounced value only updates when the user pauses for delay milliseconds.


Using It in a Search Component

// components/SearchBar.tsx
import { useState } from "react";
import useDebounce from "../hooks/useDebounce";

function SearchBar() {
  const [query, setQuery] = useState("");
  const debouncedQuery = useDebounce(query, 400);

  // This effect only runs when the user stops typing for 400ms
  useEffect(() => {
    if (!debouncedQuery) return;
    fetchResults(debouncedQuery);
  }, [debouncedQuery]);

  return (
    <input
      value={query}
      onChange={(e) => setQuery(e.target.value)}
      placeholder="Search..."
    />
  );
}

The UI updates instantly (smooth typing), while fetchResults only fires when the user pauses. Best of both worlds.


Debouncing a Callback Directly

Sometimes you want to debounce a function rather than a value — for example, a form auto-save:

// hooks/useDebouncedCallback.ts
import { useCallback, useRef } from "react";

function useDebouncedCallback<T extends (...args: any[]) => void>(
  fn: T,
  delay: number
): T {
  const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

  return useCallback(
    (...args: Parameters<T>) => {
      if (timerRef.current) clearTimeout(timerRef.current);
      timerRef.current = setTimeout(() => fn(...args), delay);
    },
    [fn, delay]
  ) as T;
}

export default useDebouncedCallback;
// Usage: auto-save a note every 600ms after the user stops typing
function NoteEditor() {
  const saveNote = useDebouncedCallback((content: string) => {
    api.save({ content });
  }, 600);

  return (
    <textarea
      onChange={(e) => saveNote(e.target.value)}
      placeholder="Start writing..."
    />
  );
}

Using a Library (The Pragmatic Choice)

If you're already using lodash, its debounce is battle-tested and handles edge cases well:

import { useMemo } from "react";
import debounce from "lodash/debounce";

function SearchBar() {
  const handleSearch = useMemo(
    () =>
      debounce((value: string) => {
        fetchResults(value);
      }, 400),
    [] // Create once; stable reference across renders
  );

  // ⚠️ Important: cancel on unmount to prevent memory leaks
  useEffect(() => {
    return () => handleSearch.cancel();
  }, [handleSearch]);

  return <input onChange={(e) => handleSearch(e.target.value)} />;
}

Why useMemo and not useCallback? Lodash's debounce returns a new function object with its own internal timer state. Using useMemo ensures you get the same debounced function instance across renders, preserving that state correctly.


Choosing the Right Delay

| Use Case | Recommended Delay | |---|---| | Search / autocomplete | 300–500ms | | Form auto-save | 500–1000ms | | Window resize handler | 150–250ms | | Scroll event handler | 100–200ms |


Common Gotchas

1. Forgetting the cleanup Without clearTimeout in your useEffect cleanup, you'll fire stale callbacks after a component unmounts — classic memory leak territory.

2. Re-creating the debounced function every render If you define your debounced function inside the component body without useMemo or useCallback, a new function (with a fresh timer) is created on every render. Your debounce never actually fires.

3. Debouncing vs. Throttling These are often confused. Debouncing waits for a pause — good for search. Throttling fires at a fixed interval — good for scroll or resize events where you want regular updates, not just the final one.


Summary

Debouncing is one of those small techniques that has an outsized impact on performance and UX. The custom useDebounce hook covers 90% of use cases cleanly. For more complex scenarios — leading edge calls, cancellation, throttling — reach for lodash/debounce or a library like use-debounce.

Your users (and your servers) will thank you.