AdSense Leaderboard

Google AdSense Placeholder

HEADER SLOT

Build a Custom useDebounce Hook: Optimize Expensive Operations

Last updated:
useDebounce Hook: Optimize Expensive Operations in React

Master debouncing in React with a custom hook. Learn to throttle API calls, search input, and event handlers with production-ready code and real-world patterns.

# 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

  1. Understanding Debouncing
  2. The Basic Hook Implementation
  3. TypeScript-First Design
  4. Advanced Patterns
  5. Debounce vs Throttle
  6. Practical Application: Live Search
  7. Memory Management and Cleanup
  8. 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

typescript
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

javascript
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

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

typescript
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

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

typescript
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

typescript
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
typescript
// 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;
}

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

typescript
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

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

  1. Separate immediate state: searchQuery updates immediately for responsive input
  2. Debounced state: debouncedQuery triggers the expensive API call
  3. Abort pattern: AbortController cancels in-flight requests if user types again
  4. Loading states: Both isPending (waiting for debounce) and isLoading (fetching)
  5. Error handling: Network failures and validation errors both handled
  6. Accessibility: Proper ARIA attributes for screen readers

# Memory Management and Cleanup

Debouncing involves timers—managing cleanup properly prevents memory leaks:

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

typescript
// ❌ 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():

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

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


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!

Sponsored Content

Google AdSense Placeholder

CONTENT SLOT

Sponsored

Google AdSense Placeholder

FOOTER SLOT