Debouncing with React Query's useQuery
Problem Statement
When implementing search or filter functionality in React applications, frequent API requests triggered by user input can cause performance issues. The problem arises when using React Query's useQuery
hook to manage these requests while trying to integrate debouncing logic. Attempting to directly wrap the query function in a debounced function leads to a "query function must return a defined value" error since useQuery
expects an immediate promise return, while debounced functions return different function instances.
Why Debouncing Matters
- Performance optimization: Reduces server load and network traffic
- User experience improvement: Prevents UI flickering and rapid loading states
- Cost reduction: Minimizes unnecessary API calls, especially in pay-per-request systems
Solution: Debounce Query Parameters
The optimal approach is to debounce the parameters used in the query key, not the query function itself. This leverages React Query's built-in refetching mechanism while maintaining proper promise handling.
Example Implementation
import { useState, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import axios from 'axios';
const useDebounce = <T,>(value: T, delay: number): T => {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => clearTimeout(handler);
}, [value, delay]);
return debouncedValue;
};
function ProductList() {
const [searchTerm, setSearchTerm] = useState('');
const debouncedSearchTerm = useDebounce(searchTerm, 500); // 500ms delay
const { data, isPending } = useQuery({
queryKey: ['products', debouncedSearchTerm],
queryFn: async () => {
const response = await axios.get(`/api/products?search=${debouncedSearchTerm}`);
return response.data;
},
placeholderData: keepPreviousData, // Smooth transitions
staleTime: 1000 * 60, // 1 minute cache
});
return (
<div>
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search products..."
/>
{isPending ? (
<p>Loading...</p>
) : (
<ul>
{data?.map(product => (
<li key={product.id}>{product.name}</li>
))}
</ul>
)}
</div>
);
}
Key Elements:
useDebounce
custom hook delays parameter updates- Debounced value included in
queryKey
- Query automatically triggers only on debounced changes
placeholderData: keepPreviousData
maintains previous results during loading
Best Practices
- Stale Time: Set
staleTime
slightly longer than debounce delay - Caching Optimization: Avoid duplicate requests through query key management
- Placeholder Data: Use
placeholderData: keepPreviousData
for seamless transitions
Alternative Approach: AbortSignal Method
For scenarios where parameter-based debouncing is impractical, use an abort signal and manual delay within the query function:
const { data } = useQuery({
queryKey: ['category', searchTerm],
queryFn: async ({ signal }) => {
// Artificial debounce delay
await new Promise(resolve => setTimeout(resolve, 500));
// Abort if new request started
if (signal?.aborted) throw new Error('Aborted');
const response = await axios.get(`/api/category?search=${searchTerm}`, {
signal, // Pass signal to axios
});
return response.data;
}
});
Key Advantages:
- Uses native abort controller pattern
- Automatically cancels previous redundant requests
- Works with any HTTP library supporting cancellation
Limitations
- Still runs query functions multiple times (delayed rather than prevented)
- May cause unnecessary function executions
- Prioritize the parameter debouncing approach when possible
Common Implementation Mistakes
Avoid these anti-patterns when implementing debounce:
// ❌ Incorrect: Debouncing the query function directly
const debouncedFn = debounce(fetchProducts, 500);
useQuery({ queryFn: debouncedFn });
// ❌ Problem: Returns undefined initially
// ❌ Unnecessary: Complex useEffect chains
useEffect(() => {
if (!term) return;
const timeoutId = setTimeout(() => setDebounced(term), 500);
return () => clearTimeout(timeoutId);
}, [term]);
useEffect(() => {
if (debounced) refetch();
}, [debounced]);
// ❌ Overcomplicates what React Query handles automatically
UX Considerations
- Immediate feedback: Show loading states during debounce delay
- Empty states: Clearly indicate no results instead of blank areas
- Input indicators: Visual feedback like loading spinners when debouncing is active
- Accessibility: Ensure loading states are announced by screen readers
<input
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
aria-busy={isPending} // Accessibility enhancement
/>
Performance Optimization
- Memorization: Wrap expensive computations with
useMemo
- Stale time: Increase to reduce background refetches
- Pagination: Combine with paginated queries for large datasets
- Caching: Leverage query keys to maximize cache utilization
- Dependency reduction: Exclude non-essential values from query keys
When implemented correctly, debouncing with React Query significantly improves application performance while maintaining seamless user experiences. The parameter debounce pattern ensures optimal API request handling while keeping your component logic clean and React-focused.