Intermediate60 min read

Throttling and Debouncing in React

Learn how to optimize event handlers and API calls using throttling and debouncing techniques to improve performance and user experience.

Topics Covered:

ThrottlingDebouncingPerformance OptimizationEvent HandlersCustom HooksAPI Optimization

Prerequisites:

  • Managing State with useState
  • Understanding useEffect
  • Event Handling in React

Video Tutorial

Overview

Throttling and debouncing are essential techniques for optimizing performance in React applications. They help control how often functions execute, which is crucial for handling frequent events like scrolling, resizing, typing, or API calls. This tutorial covers the differences between throttling and debouncing, when to use each, and how to implement them effectively in React components using custom hooks.

Understanding Throttling vs Debouncing

Throttling and debouncing are often confused, but they serve different purposes. Throttling: • Limits function execution to at most once per time period • Executes the function at regular intervals • Useful for events that fire continuously (scroll, resize, mousemove) • Example: Update scroll position indicator every 100ms Debouncing: • Delays function execution until after a period of inactivity • Executes only after user stops the action • Useful for events that should trigger after completion (search input, form validation) • Example: Search API call only after user stops typing for 300ms Key Difference: • Throttling: "Execute at most once per X milliseconds" • Debouncing: "Execute only if X milliseconds have passed since last call" Visual Analogy: • Throttling: Like a metronome - regular beats • Debouncing: Like an elevator - waits for everyone to get on before moving

Code Example:
// Throttling: Execute at most once per 100ms
// If called 10 times in 50ms, executes 2 times (at 0ms and 100ms)

// Debouncing: Execute only after 300ms of inactivity
// If called 10 times in 500ms, executes once (300ms after last call)

// Example scenario: User typing "hello"
// Throttling (100ms): Executes at h, l, o (3 times)
// Debouncing (300ms): Executes once after "o" + 300ms (1 time)

Throttling ensures regular execution, while debouncing waits for a pause in activity. Choose throttling for continuous events and debouncing for events that should trigger after completion.

Implementing Debouncing

Debouncing is perfect for search inputs, form validation, and API calls triggered by user input. When to Use Debouncing: • Search input fields • Form validation on typing • API calls triggered by input • Window resize handlers (sometimes) • Auto-save functionality Benefits: • Reduces unnecessary API calls • Improves performance • Better user experience (less flickering) • Saves server resources Implementation Steps: 1. Create a debounce function 2. Use useMemo or useCallback to create debounced version 3. Clean up on unmount 4. Handle edge cases (immediate execution, cancellation)

Code Example:
// Basic debounce implementation
function debounce<T extends (...args: unknown[]) => unknown>(
  func: T,
  wait: number
): (...args: Parameters<T>) => void {
  let timeoutId: NodeJS.Timeout | null = null;
  
  return function executedFunction(...args: Parameters<T>) {
    // Clear previous timeout
    if (timeoutId) {
      clearTimeout(timeoutId);
    }
    
    // Set new timeout
    timeoutId = setTimeout(() => {
      func(...args);
    }, wait);
  };
}

// Using debounce in React component
function SearchInput() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState<string[]>([]);
  
  // Debounced search function
  const debouncedSearch = useMemo(
    () => debounce(async (searchQuery: string) => {
      if (!searchQuery.trim()) {
        setResults([]);
        return;
      }
      
      // Simulate API call
      const response = await fetch(`/api/search?q=${searchQuery}`);
      const data = await response.json();
      setResults(data.results);
    }, 300),
    [] // Only create once
  );
  
  // Cleanup on unmount
  useEffect(() => {
    return () => {
      // Cancel pending debounced calls
      debouncedSearch.cancel?.();
    };
  }, [debouncedSearch]);
  
  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const value = e.target.value;
    setQuery(value);
    debouncedSearch(value);
  };
  
  return (
    <div>
      <input
        type="text"
        value={query}
        onChange={handleChange}
        placeholder="Search..."
      />
      <ul>
        {results.map((result, idx) => (
          <li key={idx}>{result}</li>
        ))}
      </ul>
    </div>
  );
}

// Enhanced debounce with immediate execution option
function debounceImmediate<T extends (...args: unknown[]) => unknown>(
  func: T,
  wait: number,
  immediate = false
): (...args: Parameters<T>) => void {
  let timeoutId: NodeJS.Timeout | null = null;
  
  return function executedFunction(...args: Parameters<T>) {
    const callNow = immediate && !timeoutId;
    
    if (timeoutId) {
      clearTimeout(timeoutId);
    }
    
    timeoutId = setTimeout(() => {
      timeoutId = null;
      if (!immediate) {
        func(...args);
      }
    }, wait);
    
    if (callNow) {
      func(...args);
    }
  };
}

Debouncing delays execution until after a period of inactivity. Use it for search inputs and API calls to reduce unnecessary requests and improve performance.

Implementing Throttling

Throttling ensures functions execute at most once per time period, perfect for continuous events. When to Use Throttling: • Scroll event handlers • Window resize handlers • Mouse move tracking • Touch move events • Infinite scroll loading • Analytics tracking Benefits: • Prevents excessive function calls • Maintains responsiveness • Reduces performance impact • Smooth user experience Implementation Approaches: 1. Leading edge: Execute immediately, then throttle 2. Trailing edge: Execute at end of time period 3. Both: Execute immediately and at end

Code Example:
// Basic throttle implementation (leading edge)
function throttle<T extends (...args: unknown[]) => unknown>(
  func: T,
  limit: number
): (...args: Parameters<T>) => void {
  let inThrottle: boolean;
  
  return function executedFunction(...args: Parameters<T>) {
    if (!inThrottle) {
      func(...args);
      inThrottle = true;
      setTimeout(() => {
        inThrottle = false;
      }, limit);
    }
  };
}

// Throttle with trailing edge option
function throttleTrailing<T extends (...args: unknown[]) => unknown>(
  func: T,
  limit: number
): (...args: Parameters<T>) => void {
  let lastFunc: NodeJS.Timeout | null = null;
  let lastRan: number = 0;
  
  return function executedFunction(...args: Parameters<T>) {
    if (!lastRan) {
      func(...args);
      lastRan = Date.now();
    } else {
      if (lastFunc) {
        clearTimeout(lastFunc);
      }
      lastFunc = setTimeout(() => {
        if (Date.now() - lastRan >= limit) {
          func(...args);
          lastRan = Date.now();
        }
      }, limit - (Date.now() - lastRan));
    }
  };
}

// Using throttle in React component
function ScrollTracker() {
  const [scrollY, setScrollY] = useState(0);
  const [scrollCount, setScrollCount] = useState(0);
  
  useEffect(() => {
    const handleScroll = throttle(() => {
      setScrollY(window.scrollY);
      setScrollCount(prev => prev + 1);
    }, 100); // Execute at most once per 100ms
    
    window.addEventListener('scroll', handleScroll);
    
    return () => {
      window.removeEventListener('scroll', handleScroll);
    };
  }, []);
  
  return (
    <div>
      <p>Scroll Position: {scrollY}px</p>
      <p>Scroll Events Processed: {scrollCount}</p>
      <p className="text-sm text-muted-foreground">
        (Throttled to max 10 times per second)
      </p>
    </div>
  );
}

// Throttled resize handler
function ResponsiveComponent() {
  const [windowWidth, setWindowWidth] = useState(window.innerWidth);
  
  useEffect(() => {
    const handleResize = throttle(() => {
      setWindowWidth(window.innerWidth);
    }, 250);
    
    window.addEventListener('resize', handleResize);
    
    return () => {
      window.removeEventListener('resize', handleResize);
    };
  }, []);
  
  return (
    <div>
      <p>Window Width: {windowWidth}px</p>
      {windowWidth < 768 && <p>Mobile View</p>}
      {windowWidth >= 768 && <p>Desktop View</p>}
    </div>
  );
}

Throttling limits function execution frequency. Use it for continuous events like scrolling and resizing to maintain performance while still responding to user actions.

Custom React Hooks for Throttling and Debouncing

Creating custom hooks makes throttling and debouncing reusable across components. Benefits of Custom Hooks: • Reusable logic • Clean component code • Proper cleanup handling • Type safety • Easy testing Hook Design Considerations: • Handle cleanup on unmount • Support dependency arrays • Allow configuration (delay, immediate) • Return cancel function • TypeScript support

Code Example:
// Custom useDebounce hook
function useDebounce<T>(value: T, delay: number): T {
  const [debouncedValue, setDebouncedValue] = useState<T>(value);
  
  useEffect(() => {
    // Set timeout to update debounced value
    const handler = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);
    
    // Cleanup timeout on value change or unmount
    return () => {
      clearTimeout(handler);
    };
  }, [value, delay]);
  
  return debouncedValue;
}

// Usage: Debounce search query
function SearchComponent() {
  const [query, setQuery] = useState('');
  const debouncedQuery = useDebounce(query, 300);
  const [results, setResults] = useState<string[]>([]);
  
  useEffect(() => {
    if (debouncedQuery) {
      // API call only happens after user stops typing
      fetch(`/api/search?q=${debouncedQuery}`)
        .then(res => res.json())
        .then(data => setResults(data.results));
    }
  }, [debouncedQuery]);
  
  return (
    <div>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search..."
      />
      <ul>
        {results.map((result, idx) => (
          <li key={idx}>{result}</li>
        ))}
      </ul>
    </div>
  );
}

// Custom useDebounceCallback hook
function useDebounceCallback<T extends (...args: unknown[]) => unknown>(
  callback: T,
  delay: number
): T {
  const timeoutRef = useRef<NodeJS.Timeout>();
  const callbackRef = useRef(callback);
  
  // Update callback ref when callback changes
  useEffect(() => {
    callbackRef.current = callback;
  }, [callback]);
  
  const debouncedCallback = useCallback(
    ((...args: Parameters<T>) => {
      if (timeoutRef.current) {
        clearTimeout(timeoutRef.current);
      }
      
      timeoutRef.current = setTimeout(() => {
        callbackRef.current(...args);
      }, delay);
    }) as T,
    [delay]
  );
  
  // Cleanup on unmount
  useEffect(() => {
    return () => {
      if (timeoutRef.current) {
        clearTimeout(timeoutRef.current);
      }
    };
  }, []);
  
  return debouncedCallback;
}

// Custom useThrottle hook
function useThrottle<T>(value: T, limit: number): T {
  const [throttledValue, setThrottledValue] = useState<T>(value);
  const lastRan = useRef<number>(Date.now());
  
  useEffect(() => {
    const handler = setTimeout(() => {
      if (Date.now() - lastRan.current >= limit) {
        setThrottledValue(value);
        lastRan.current = Date.now();
      }
    }, limit - (Date.now() - lastRan.current));
    
    return () => {
      clearTimeout(handler);
    };
  }, [value, limit]);
  
  return throttledValue;
}

// Custom useThrottleCallback hook
function useThrottleCallback<T extends (...args: unknown[]) => unknown>(
  callback: T,
  limit: number
): T {
  const lastRan = useRef<number>(0);
  const callbackRef = useRef(callback);
  
  useEffect(() => {
    callbackRef.current = callback;
  }, [callback]);
  
  const throttledCallback = useCallback(
    ((...args: Parameters<T>) => {
      const now = Date.now();
      if (now - lastRan.current >= limit) {
        callbackRef.current(...args);
        lastRan.current = now;
      }
    }) as T,
    [limit]
  );
  
  return throttledCallback;
}

// Usage: Throttled scroll handler
function ScrollComponent() {
  const [scrollY, setScrollY] = useState(0);
  
  const handleScroll = useThrottleCallback(() => {
    setScrollY(window.scrollY);
  }, 100);
  
  useEffect(() => {
    window.addEventListener('scroll', handleScroll);
    return () => window.removeEventListener('scroll', handleScroll);
  }, [handleScroll]);
  
  return <div>Scroll Position: {scrollY}px</div>;
}

Custom hooks encapsulate throttling and debouncing logic, making it reusable and easier to use in components. They handle cleanup automatically and provide a clean API.

Real-World Use Cases

Understanding when and how to apply throttling and debouncing in real applications. Common Scenarios: 1. Search Input (Debounce): • User types search query • Wait 300ms after typing stops • Then make API call • Reduces API calls from 10+ to 1 2. Infinite Scroll (Throttle): • User scrolls down page • Check scroll position every 200ms • Load more content when near bottom • Prevents excessive checks 3. Form Validation (Debounce): • User types in email field • Wait 500ms after typing stops • Then validate email format • Better UX than validating on every keystroke 4. Window Resize (Throttle): • User resizes browser window • Update layout calculations every 250ms • Prevents layout thrashing 5. Button Click (Debounce): • Prevent double-clicks • Disable button for 1000ms after click • Prevents duplicate submissions 6. Analytics Tracking (Throttle): • Track scroll depth • Send analytics every 2 seconds • Reduces network requests Best Practices: • Use debounce for user input (search, validation) • Use throttle for continuous events (scroll, resize) • Choose appropriate delays (100-500ms common) • Always cleanup in useEffect • Test with fast user interactions

Code Example:
// 1. Search with debounce
function SearchPage() {
  const [query, setQuery] = useState('');
  const debouncedQuery = useDebounce(query, 300);
  const [results, setResults] = useState([]);
  const [loading, setLoading] = useState(false);
  
  useEffect(() => {
    if (!debouncedQuery) {
      setResults([]);
      return;
    }
    
    setLoading(true);
    searchAPI(debouncedQuery)
      .then(data => {
        setResults(data);
        setLoading(false);
      })
      .catch(() => setLoading(false));
  }, [debouncedQuery]);
  
  return (
    <div>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search products..."
      />
      {loading && <p>Searching...</p>}
      <ResultsList results={results} />
    </div>
  );
}

// 2. Infinite scroll with throttle
function InfiniteScrollList() {
  const [items, setItems] = useState([]);
  const [loading, setLoading] = useState(false);
  const [hasMore, setHasMore] = useState(true);
  
  const checkScroll = useThrottleCallback(() => {
    const scrollTop = window.scrollY;
    const windowHeight = window.innerHeight;
    const documentHeight = document.documentElement.scrollHeight;
    
    // Load more when 200px from bottom
    if (documentHeight - (scrollTop + windowHeight) < 200 && hasMore && !loading) {
      loadMore();
    }
  }, 200);
  
  const loadMore = async () => {
    setLoading(true);
    const newItems = await fetchMoreItems();
    setItems(prev => [...prev, ...newItems]);
    setHasMore(newItems.length > 0);
    setLoading(false);
  };
  
  useEffect(() => {
    window.addEventListener('scroll', checkScroll);
    return () => window.removeEventListener('scroll', checkScroll);
  }, [checkScroll, hasMore, loading]);
  
  return <ItemList items={items} />;
}

// 3. Form validation with debounce
function EmailInput() {
  const [email, setEmail] = useState('');
  const [error, setError] = useState('');
  const debouncedEmail = useDebounce(email, 500);
  
  useEffect(() => {
    if (!debouncedEmail) {
      setError('');
      return;
    }
    
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    if (!emailRegex.test(debouncedEmail)) {
      setError('Please enter a valid email address');
    } else {
      setError('');
    }
  }, [debouncedEmail]);
  
  return (
    <div>
      <input
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        placeholder="Email"
      />
      {error && <p className="text-red-500">{error}</p>}
    </div>
  );
}

// 4. Button click debounce (prevent double-click)
function SubmitButton() {
  const [disabled, setDisabled] = useState(false);
  
  const handleSubmit = useDebounceCallback(async () => {
    setDisabled(true);
    await submitForm();
    setDisabled(false);
  }, 1000);
  
  return (
    <button onClick={handleSubmit} disabled={disabled}>
      {disabled ? 'Submitting...' : 'Submit'}
    </button>
  );
}

// 5. Analytics tracking with throttle
function ScrollAnalytics() {
  const trackScroll = useThrottleCallback(() => {
    const scrollPercent = 
      (window.scrollY / (document.documentElement.scrollHeight - window.innerHeight)) * 100;
    
    // Send to analytics (throttled to every 2 seconds)
    analytics.track('scroll_depth', { percent: Math.round(scrollPercent) });
  }, 2000);
  
  useEffect(() => {
    window.addEventListener('scroll', trackScroll);
    return () => window.removeEventListener('scroll', trackScroll);
  }, [trackScroll]);
  
  return null; // This is just for tracking
}

Real-world applications use throttling and debouncing for search, infinite scroll, form validation, button clicks, and analytics. Choose the right technique based on the use case.

Performance Considerations and Best Practices

Understanding performance implications and following best practices ensures optimal results. Performance Tips: • Choose appropriate delays (100-500ms common) • Use debounce for expensive operations (API calls) • Use throttle for frequent events (scroll, resize) • Always cleanup timeouts/intervals • Consider using requestAnimationFrame for animations • Test with fast user interactions Common Pitfalls: • Forgetting to cleanup (memory leaks) • Creating new debounced/throttled functions on every render • Using wrong technique for the use case • Too short delays (poor UX) • Too long delays (feels unresponsive) Best Practices: • Use useMemo/useCallback for stable references • Cleanup in useEffect return • Test edge cases (rapid clicking, fast typing) • Consider user experience • Monitor performance impact • Use TypeScript for type safety

Code Example:
// ❌ BAD: Creating new debounced function on every render
function BadSearch() {
  const [query, setQuery] = useState('');
  
  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const value = e.target.value;
    setQuery(value);
    
    // ❌ New debounced function created every render!
    const debounced = debounce(() => {
      searchAPI(value);
    }, 300);
    debounced();
  };
  
  return <input value={query} onChange={handleChange} />;
}

// ✅ GOOD: Stable debounced function reference
function GoodSearch() {
  const [query, setQuery] = useState('');
  
  const debouncedSearch = useMemo(
    () => debounce((searchQuery: string) => {
      searchAPI(searchQuery);
    }, 300),
    [] // Only create once
  );
  
  // Cleanup
  useEffect(() => {
    return () => {
      debouncedSearch.cancel?.();
    };
  }, [debouncedSearch]);
  
  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const value = e.target.value;
    setQuery(value);
    debouncedSearch(value);
  };
  
  return <input value={query} onChange={handleChange} />;
}

// ❌ BAD: No cleanup (memory leak)
function BadThrottle() {
  useEffect(() => {
    const handler = throttle(() => {
      console.log('scroll');
    }, 100);
    
    window.addEventListener('scroll', handler);
    // ❌ Missing cleanup!
  }, []);
  
  return <div>Content</div>;
}

// ✅ GOOD: Proper cleanup
function GoodThrottle() {
  useEffect(() => {
    const handler = throttle(() => {
      console.log('scroll');
    }, 100);
    
    window.addEventListener('scroll', handler);
    
    // ✅ Cleanup on unmount
    return () => {
      window.removeEventListener('scroll', handler);
    };
  }, []);
  
  return <div>Content</div>;
}

// Performance comparison
function PerformanceDemo() {
  const [count, setCount] = useState(0);
  const [throttledCount, setThrottledCount] = useState(0);
  
  // Without throttle: fires on every scroll
  useEffect(() => {
    const handler = () => setCount(prev => prev + 1);
    window.addEventListener('scroll', handler);
    return () => window.removeEventListener('scroll', handler);
  }, []);
  
  // With throttle: fires max once per 100ms
  useEffect(() => {
    const handler = throttle(() => {
      setThrottledCount(prev => prev + 1);
    }, 100);
    window.addEventListener('scroll', handler);
    return () => window.removeEventListener('scroll', handler);
  }, []);
  
  return (
    <div>
      <p>Scroll Events (no throttle): {count}</p>
      <p>Scroll Events (throttled): {throttledCount}</p>
      <p className="text-sm text-muted-foreground">
        Scroll quickly to see the difference!
      </p>
    </div>
  );
}

// Choosing the right delay
const DELAY_GUIDE = {
  // Debounce delays
  search: 300,        // Search input: 300ms
  validation: 500,    // Form validation: 500ms
  autosave: 1000,    // Auto-save: 1000ms
  
  // Throttle delays
  scroll: 100,       // Scroll tracking: 100ms
  resize: 250,       // Window resize: 250ms
  mousemove: 16,     // Mouse move (60fps): 16ms
  analytics: 2000,   // Analytics: 2000ms
};

Follow best practices: use stable function references, always cleanup, choose appropriate delays, and test performance. Avoid common pitfalls like memory leaks and recreating functions on every render.

Conclusion

Throttling and debouncing are essential techniques for optimizing React applications. Use debouncing for user input and API calls that should wait for completion. Use throttling for continuous events like scrolling and resizing. Create custom hooks for reusability, always handle cleanup properly, and choose appropriate delays based on your use case. These techniques significantly improve performance and user experience by reducing unnecessary function executions and API calls. Remember: debounce waits for a pause, throttle limits frequency - choose the right tool for the job.