Debouncing in React: A Practical Guide
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
useMemoand notuseCallback? Lodash'sdebouncereturns a new function object with its own internal timer state. UsinguseMemoensures 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.