Build a Custom useDebounce Hook: Optimize Expensive Operations
Every developer has experienced this: a user types something into a search box, and your app sends an API request for every keystroke. The server gets hammered with requests, the API quota drains, and users wait for results that are already outdated. This is where debouncing comes in—a technique that delays function execution until a burst of events settles.
In React applications at scale, debouncing isn't optional. It's fundamental to performance. Whether you're handling search queries at Alibaba's scale, managing form validation, or triggering expensive computations, a well-implemented useDebounce hook becomes one of your most-reached-for utilities.
This guide walks you through building a production-ready debounce hook from first principles, implementing advanced patterns like leading/trailing edge control, and solving real problems that developers encounter in large applications.
Table of Contents
- Understanding Debouncing
- The Basic Hook Implementation
- TypeScript-First Design
- Advanced Patterns
- Debounce vs Throttle
- Practical Application: Live Search
- Memory Management and Cleanup
- FAQ
Understanding Debouncing
Debouncing is a programming practice used to ensure that time-consuming tasks don't fire so often that they bog down the browser. The concept is simple: delay the execution of a function until after a certain amount of time has passed since the last time it was invoked.
Consider a typical search scenario:
- User types "r" → network request for "r"
- User types "react" → network request for "react" (previous request still pending)
- User types "react hooks" → network request for "react hooks" (two previous requests wasted)
Without debouncing, three API calls fire for results the user doesn't want. With a 300ms debounce, we wait until the user stops typing, then make exactly one API call.
Key insight: Debouncing is about delaying work, not skipping it. Every distinct "burst" of activity gets handled—just once, after the burst settles.
The Basic Hook Implementation
Let's build the simplest version that handles the core use case: debouncing a value that changes frequently.
TypeScript Version
import { useState, useEffect, useRef } from 'react';
export function useDebounce<T>(value: T, delayMs: number = 500): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
// Set up a timer to update the debounced value
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delayMs);
// Clean up the timer if value changes before delay expires
return () => clearTimeout(handler);
}, [value, delayMs]);
return debouncedValue;
}
JavaScript Version
import { useState, useEffect } from 'react';
export function useDebounce(value, delayMs = 500) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
// Set up a timer to update the debounced value
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delayMs);
// Clean up the timer if value changes before delay expires
return () => clearTimeout(handler);
}, [value, delayMs]);
return debouncedValue;
}
How It Works
The cleanup function: When the effect runs again (because value changed), React calls the cleanup function first, which clears the pending timeout. This prevents stale updates—if the user types again before the delay expires, the old scheduled update never happens.
The dependency array: Both value and delayMs are dependencies. If either changes, the effect re-runs, resetting the timer. This ensures debouncing works correctly even if the delay changes at runtime.
Using It
function SearchUsers() {
const [searchTerm, setSearchTerm] = useState('');
const debouncedSearchTerm = useDebounce(searchTerm, 300);
useEffect(() => {
if (!debouncedSearchTerm) return;
// This only runs after user stops typing for 300ms
const controller = new AbortController();
fetch(`/api/users?q=${debouncedSearchTerm}`, {
signal: controller.signal,
})
.then(res => res.json())
.then(data => setResults(data))
.catch(err => {
if (err.name !== 'AbortError') {
console.error(err);
}
});
return () => controller.abort();
}, [debouncedSearchTerm]);
return (
<>
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search users..."
/>
</>
);
}
The key pattern: the immediate state (searchTerm) updates as the user types for smooth UI feedback, while the debounced value triggers expensive operations only after typing settles.
TypeScript-First Design
For teams requiring strict type safety, here's an enhanced version with better ergonomics:
import { useState, useEffect, useRef, useCallback } from 'react';
interface DebouncedValue<T> {
value: T;
isPending: boolean;
}
export function useDebounce<T>(
value: T,
delayMs: number = 500,
options: { immediate?: boolean } = {}
): DebouncedValue<T> {
const { immediate = false } = options;
const [debouncedValue, setDebouncedValue] = useState<T>(value);
const [isPending, setIsPending] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => {
setIsPending(true);
// If immediate is true, update right away and set timer for subsequent cancellation
if (immediate) {
setDebouncedValue(value);
setIsPending(false);
}
const handler = setTimeout(() => {
setDebouncedValue(value);
setIsPending(false);
}, delayMs);
// Store timer reference for manual cancellation if needed
timerRef.current = handler;
return () => {
clearTimeout(handler);
setIsPending(false);
};
}, [value, delayMs, immediate]);
return { value: debouncedValue, isPending };
}
The isPending flag: Tells you when a debounced update is scheduled. Useful for showing loading spinners or disabling submit buttons until the debounce settles.
The immediate option: Updates the value right away, then resets the timer. Useful for button clicks where you want immediate visual feedback but debounced API calls.
TypeScript Usage
interface SearchResults {
users: Array<{ id: string; name: string }>;
totalCount: number;
}
function LiveSearch() {
const [searchInput, setSearchInput] = useState('');
const [results, setResults] = useState<SearchResults | null>(null);
const [isLoading, setIsLoading] = useState(false);
const { value: debouncedSearch, isPending } = useDebounce(searchInput, 400);
useEffect(() => {
if (!debouncedSearch.trim()) {
setResults(null);
return;
}
setIsLoading(true);
const controller = new AbortController();
fetch(`/api/search?q=${encodeURIComponent(debouncedSearch)}`, {
signal: controller.signal,
})
.then(res => res.json() as Promise<SearchResults>)
.then(data => setResults(data))
.finally(() => setIsLoading(false))
.catch(err => {
if (err.name !== 'AbortError') console.error(err);
});
return () => controller.abort();
}, [debouncedSearch]);
return (
<div>
<input
type="text"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Search..."
aria-busy={isLoading || isPending}
/>
{isPending && <span>Waiting...</span>}
{isLoading && <span>Searching...</span>}
{results && <ResultsList items={results.users} />}
</div>
);
}
Advanced Patterns
Debounced Function Calls
Sometimes you need to debounce a function directly, not just a value. This pattern is useful for resize handlers, scroll listeners, and user interactions:
interface DebouncedFn<T extends (...args: any[]) => any> {
(...args: Parameters<T>): void;
cancel: () => void;
}
export function useDebouncedCallback<T extends (...args: any[]) => any>(
callback: T,
delayMs: number = 500,
options: { leading?: boolean; maxWait?: number } = {}
): DebouncedFn<T> {
const { leading = false, maxWait } = options;
const timerRef = useRef<ReturnType<typeof setTimeout>>();
const maxWaitTimerRef = useRef<ReturnType<typeof setTimeout>>();
const lastCallTimeRef = useRef<number>(0);
const hasCalledRef = useRef(false);
const cancel = useCallback(() => {
if (timerRef.current) clearTimeout(timerRef.current);
if (maxWaitTimerRef.current) clearTimeout(maxWaitTimerRef.current);
hasCalledRef.current = false;
}, []);
const debouncedFn = useCallback(
(...args: Parameters<T>) => {
const now = Date.now();
const timeSinceLastCall = now - lastCallTimeRef.current;
// Call immediately on leading edge if enabled
if (leading && !hasCalledRef.current) {
callback(...args);
hasCalledRef.current = true;
}
// Clear existing timer
if (timerRef.current) clearTimeout(timerRef.current);
// If maxWait is set and we've waited long enough, call immediately
if (maxWait && timeSinceLastCall >= maxWait) {
callback(...args);
lastCallTimeRef.current = now;
return;
}
// Schedule debounced call
timerRef.current = setTimeout(() => {
callback(...args);
hasCalledRef.current = false;
}, delayMs);
lastCallTimeRef.current = now;
},
[callback, delayMs, leading, maxWait]
);
// Cleanup on unmount
useEffect(() => {
return () => cancel();
}, [cancel]);
debouncedFn.cancel = cancel;
return debouncedFn as DebouncedFn<T>;
}
Window Resize Handler
function ResponsiveLayout() {
const [windowWidth, setWindowWidth] = useState(window.innerWidth);
const handleResize = useDebouncedCallback(
() => {
setWindowWidth(window.innerWidth);
console.log(`Window resized to ${window.innerWidth}px`);
},
300,
{ maxWait: 1000 } // Force update after 1 second regardless
);
useEffect(() => {
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
handleResize.cancel(); // Clean up the debounced function
};
}, [handleResize]);
return <div>Current width: {windowWidth}px</div>;
}
The maxWait option is crucial here—it ensures the layout updates even if the user continuously resizes the window, preventing the interface from feeling unresponsive.
Debounce vs Throttle
These concepts are often confused. Here's the critical difference:
| Aspect | Debounce | Throttle |
|---|---|---|
| Execution | After a burst settles | At regular intervals |
| Use Case | Search input, form validation | Scroll events, mouse move tracking |
| Frequency | Zero to one call per burst | Multiple calls during activity |
| Example | User types 5 times, 1 API call | User scrolls for 2s, called every 200ms |
When to debounce:
- Autocomplete search
- Form field validation
- Resize window
- Save draft content
When to throttle:
- Scroll position tracking
- Mouse movement tracking
- Window resize events (when you want periodic updates)
- Drag and drop operations
// Throttle example for comparison
export function useThrottle<T>(value: T, delayMs: number = 500): T {
const [throttledValue, setThrottledValue] = useState<T>(value);
const lastUpdateRef = useRef<number>(Date.now());
useEffect(() => {
const now = Date.now();
const timeSinceLastUpdate = now - lastUpdateRef.current;
if (timeSinceLastUpdate >= delayMs) {
lastUpdateRef.current = now;
setThrottledValue(value);
} else {
const handler = setTimeout(() => {
lastUpdateRef.current = Date.now();
setThrottledValue(value);
}, delayMs - timeSinceLastUpdate);
return () => clearTimeout(handler);
}
}, [value, delayMs]);
return throttledValue;
}
Practical Application: Live Search
Let's implement a production-ready search component that combines everything we've learned. This pattern is used by developers at Tencent, ByteDance, and Alibaba for their search features:
TypeScript Version
interface SearchResult {
id: string;
title: string;
category: string;
}
interface SearchState {
results: SearchResult[];
isLoading: boolean;
error: string | null;
hasSearched: boolean;
}
export function LiveSearchComponent() {
const [searchQuery, setSearchQuery] = useState('');
const [searchState, setSearchState] = useState<SearchState>({
results: [],
isLoading: false,
error: null,
hasSearched: false,
});
const { value: debouncedQuery, isPending } = useDebounce(searchQuery, 400);
useEffect(() => {
// Don't search if query is empty
if (!debouncedQuery.trim()) {
setSearchState({
results: [],
isLoading: false,
error: null,
hasSearched: false,
});
return;
}
setSearchState(prev => ({ ...prev, isLoading: true, error: null }));
const controller = new AbortController();
const timer = setTimeout(() => {
fetch(`/api/search?q=${encodeURIComponent(debouncedQuery)}`, {
signal: controller.signal,
})
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json() as Promise<SearchResult[]>;
})
.then(results => {
setSearchState({
results,
isLoading: false,
error: null,
hasSearched: true,
});
})
.catch(err => {
if (err.name === 'AbortError') return;
setSearchState(prev => ({
...prev,
isLoading: false,
error: 'Failed to fetch results',
hasSearched: true,
}));
});
}, 0);
return () => {
clearTimeout(timer);
controller.abort();
};
}, [debouncedQuery]);
const isEmpty = !searchQuery.trim();
const hasResults = searchState.results.length > 0;
const showNoResults = searchState.hasSearched && !hasResults && !searchState.isLoading;
return (
<div className="search-container">
<div className="search-input-group">
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search documents..."
aria-busy={isPending || searchState.isLoading}
aria-describedby={searchState.error ? 'search-error' : undefined}
/>
{(isPending || searchState.isLoading) && (
<span className="search-spinner" aria-live="polite">
Searching...
</span>
)}
</div>
{searchState.error && (
<div id="search-error" className="search-error" role="alert">
{searchState.error}
</div>
)}
{showNoResults && (
<div className="no-results" role="status">
No results found for "{debouncedQuery}"
</div>
)}
{hasResults && (
<ul className="search-results">
{searchState.results.map(result => (
<li key={result.id} className="search-result-item">
<a href={`/docs/${result.id}`}>
<strong>{result.title}</strong>
<span className="category">{result.category}</span>
</a>
</li>
))}
</ul>
)}
</div>
);
}
JavaScript Version
export function LiveSearchComponent() {
const [searchQuery, setSearchQuery] = useState('');
const [searchState, setSearchState] = useState({
results: [],
isLoading: false,
error: null,
hasSearched: false,
});
const { value: debouncedQuery, isPending } = useDebounce(searchQuery, 400);
useEffect(() => {
if (!debouncedQuery.trim()) {
setSearchState({
results: [],
isLoading: false,
error: null,
hasSearched: false,
});
return;
}
setSearchState(prev => ({ ...prev, isLoading: true, error: null }));
const controller = new AbortController();
const timer = setTimeout(() => {
fetch(`/api/search?q=${encodeURIComponent(debouncedQuery)}`, {
signal: controller.signal,
})
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(results => {
setSearchState({
results,
isLoading: false,
error: null,
hasSearched: true,
});
})
.catch(err => {
if (err.name === 'AbortError') return;
setSearchState(prev => ({
...prev,
isLoading: false,
error: 'Failed to fetch results',
hasSearched: true,
}));
});
}, 0);
return () => {
clearTimeout(timer);
controller.abort();
};
}, [debouncedQuery]);
const isEmpty = !searchQuery.trim();
const hasResults = searchState.results.length > 0;
const showNoResults = searchState.hasSearched && !hasResults && !searchState.isLoading;
return (
<div className="search-container">
<div className="search-input-group">
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search documents..."
aria-busy={isPending || searchState.isLoading}
/>
{(isPending || searchState.isLoading) && (
<span className="search-spinner">Searching...</span>
)}
</div>
{searchState.error && (
<div className="search-error" role="alert">{searchState.error}</div>
)}
{showNoResults && (
<div className="no-results">No results found for "{debouncedQuery}"</div>
)}
{hasResults && (
<ul className="search-results">
{searchState.results.map(result => (
<li key={result.id}>
<a href={`/docs/${result.id}`}>
<strong>{result.title}</strong>
<span className="category">{result.category}</span>
</a>
</li>
))}
</ul>
)}
</div>
);
}
Key implementation details:
- Separate immediate state:
searchQueryupdates immediately for responsive input - Debounced state:
debouncedQuerytriggers the expensive API call - Abort pattern: AbortController cancels in-flight requests if user types again
- Loading states: Both
isPending(waiting for debounce) andisLoading(fetching) - Error handling: Network failures and validation errors both handled
- Accessibility: Proper ARIA attributes for screen readers
Memory Management and Cleanup
Debouncing involves timers—managing cleanup properly prevents memory leaks:
export function useDebounceWithCleanup<T>(
value: T,
delayMs: number = 500,
onDebounced?: (value: T) => void
) {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
const timerRef = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => {
timerRef.current = setTimeout(() => {
setDebouncedValue(value);
onDebounced?.(value);
}, delayMs);
return () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
}
};
}, [value, delayMs, onDebounced]);
// Manual cancel function for explicit cleanup
const cancel = useCallback(() => {
if (timerRef.current) {
clearTimeout(timerRef.current);
}
}, []);
return { debouncedValue, cancel };
}
Why this matters: In applications with rapid component mounting/unmounting (like complex dashboards), leaked timers accumulate and cause memory bloat. Always clear timers in cleanup functions.
FAQ
Q: What's the difference between debouncing and batching state updates?
A: Debouncing delays execution of any code (including state updates). React's batching (which happens automatically) groups multiple setState calls within the same event handler into one render. They solve different problems—use debouncing to prevent frequent API calls, use batching (or flushSync when you need immediate updates) to optimize rendering.
Q: Why does my debounce seem to ignore the delay parameter?
A: Check if delayMs is in the dependency array of useEffect. If it's missing, the timer never resets when the delay changes. Always include all non-constant dependencies:
// ❌ BUG: delayMs missing from dependencies
useEffect(() => {
const handler = setTimeout(() => setDebouncedValue(value), delayMs);
return () => clearTimeout(handler);
}, [value]); // Missing delayMs!
// ✅ CORRECT: delayMs included
useEffect(() => {
const handler = setTimeout(() => setDebouncedValue(value), delayMs);
return () => clearTimeout(handler);
}, [value, delayMs]); // Both dependencies
Q: Should I debounce or handle this server-side?
A: Both when possible. Client-side debouncing prevents unnecessary network traffic and improves UX. Server-side validation still catches edge cases and prevents malicious input. Example: debounce username availability checks on the client, validate on server on form submit.
Q: How do I test debounced functions?
A: Use jest.useFakeTimers():
test('debounces value updates', () => {
jest.useFakeTimers();
const { result } = renderHook(() => useDebounce('initial', 500));
expect(result.current).toBe('initial');
// Simulate value change
rerender({ value: 'updated', delayMs: 500 });
expect(result.current).toBe('initial'); // Still old value
jest.advanceTimersByTime(500);
expect(result.current).toBe('updated'); // Now updated
jest.useRealTimers();
});
Q: Can I combine debounce with other hooks like useCallback?
A: Yes, but be careful with dependencies. The pattern is to debounce values, then use the debounced value as a dependency for memoized callbacks:
const debouncedSearch = useDebounce(searchQuery, 300);
const handleSearch = useCallback(() => {
// This callback is stable unless debouncedSearch changes
performSearch(debouncedSearch);
}, [debouncedSearch]);
Q: What delay should I use?
A: It depends on your use case:
- Form input/search: 300-500ms (balance responsiveness with performance)
- Window resize: 200-300ms
- Scroll events: 100-200ms (you probably want throttle here instead)
- API calls: 500-1000ms (more delay if you're rate-limited)
Start conservative (500ms) and reduce if users complain about responsiveness.
Related Articles:
- Understanding useEffect and Dependencies
- useCallback for Memoized Functions
- Performance Optimization Patterns
- Building Reusable Custom Hooks
Found a use case we haven't covered? Share your debouncing patterns in the comments—especially for high-frequency updates like drag operations or real-time collaboration. Let's discuss trade-offs between debouncing, throttling, and request prioritization for different scenarios!
Google AdSense Placeholder
CONTENT SLOT