Skip to content

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

tsx
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:

  1. useDebounce custom hook delays parameter updates
  2. Debounced value included in queryKey
  3. Query automatically triggers only on debounced changes
  4. 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:

tsx
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:

jsx
// ❌ Incorrect: Debouncing the query function directly
const debouncedFn = debounce(fetchProducts, 500);
useQuery({ queryFn: debouncedFn });

// ❌ Problem: Returns undefined initially
jsx
// ❌ 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
jsx
<input
  value={searchTerm}
  onChange={(e) => setSearchTerm(e.target.value)}
  aria-busy={isPending} // Accessibility enhancement
/>

Performance Optimization

  1. Memorization: Wrap expensive computations with useMemo
  2. Stale time: Increase to reduce background refetches
  3. Pagination: Combine with paginated queries for large datasets
  4. Caching: Leverage query keys to maximize cache utilization
  5. 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.