// LOADING
// LOADING
// LOADING_ARTICLE
Some events fire dozens or hundreds of times per second — scroll, mousemove, resize, and every keystroke in a search input. Running expensive logic (API calls, heavy computations, DOM measurements) on every event is wasteful and often produces incorrect behavior. Debouncing and throttling are two different solutions to this problem, and knowing which to use comes down to understanding what you want to happen.
The canonical use case: a search field that fetches results from an API. You don't want to fire a network request on every keystroke, only after the user pauses.
tsximport { useState, useEffect } from 'react'; function useDebounce<T>(value: T, delay: number): T { const [debouncedValue, setDebouncedValue] = useState<T>(value); useEffect(() => { const timer = setTimeout(() => setDebouncedValue(value), delay); return () => clearTimeout(timer); // cleanup on next render }, [value, delay]); return debouncedValue; } function SearchInput() { const [query, setQuery] = useState(''); const debouncedQuery = useDebounce(query, 300); useEffect(() => { if (!debouncedQuery) return; fetchResults(debouncedQuery).then(setResults); }, [debouncedQuery]); return ( <input value={query} onChange={e => setQuery(e.target.value)} placeholder="Search..." /> ); }
The useDebounce hook is a clean, reusable primitive. Notice the cleanup function in the useEffect — it clears the pending timeout on every render, which is exactly what prevents firing mid-sequence. This is the same cleanup pattern described in useEffect Pitfalls and How to Avoid Them.
For scroll position tracking — say, to show a "back to top" button or update a reading progress bar — you want updates during scrolling, just not 60+ times per second.
tsximport { useEffect, useRef, useState } from 'react'; function useThrottle<T>(value: T, interval: number): T { const [throttledValue, setThrottledValue] = useState<T>(value); const lastUpdated = useRef<number>(0); useEffect(() => { const now = Date.now(); if (now - lastUpdated.current >= interval) { setThrottledValue(value); lastUpdated.current = now; } else { const timerId = setTimeout(() => { setThrottledValue(value); lastUpdated.current = Date.now(); }, interval - (now - lastUpdated.current)); return () => clearTimeout(timerId); } }, [value, interval]); return throttledValue; }
For simple scroll handlers that don't need React state at all, a lower-level approach is cleaner:
tsxuseEffect(() => { let lastRun = 0; function handleScroll() { const now = Date.now(); if (now - lastRun < 100) return; // 100ms throttle lastRun = now; setScrollY(window.scrollY); } window.addEventListener('scroll', handleScroll, { passive: true }); return () => window.removeEventListener('scroll', handleScroll); }, []);
The { passive: true } option is important here — it tells the browser the handler won't call preventDefault(), enabling scroll performance optimizations.
If you're already using Lodash, its debounce and throttle utilities are well-tested and handle edge cases like leading/trailing edge invocation. The critical requirement when using them in React is that the debounced/throttled function must be stable across renders — wrap it in useCallback or useRef.
tsximport { useCallback, useRef } from 'react'; import { debounce } from 'lodash-es'; function SearchBox() { const fetchRef = useRef( debounce((query: string) => { fetchResults(query).then(setResults); }, 300) ); // Cleanup on unmount useEffect(() => { return () => fetchRef.current.cancel(); }, []); return ( <input onChange={e => fetchRef.current(e.target.value)} /> ); }
Using useRef to hold the debounced function avoids recreating it on each render. Creating a new debounced function on every render resets the internal timer — completely defeating the purpose.
Resize handlers are another classic throttle candidate. Recalculating layout dimensions on every pixel of resize is wasteful:
tsxuseEffect(() => { let timeoutId: ReturnType<typeof setTimeout>; function handleResize() { clearTimeout(timeoutId); timeoutId = setTimeout(() => { setDimensions({ width: window.innerWidth, height: window.innerHeight }); }, 150); } window.addEventListener('resize', handleResize); return () => { window.removeEventListener('resize', handleResize); clearTimeout(timeoutId); }; }, []);
This is a debounce (waits for activity to stop) rather than a throttle, which is usually right for resize — you want the final dimensions, not intermediate ones during a drag.
| Scenario | Use | |---|---| | Search input autocomplete | Debounce | | Form field validation on type | Debounce | | Scroll-linked animations | Throttle | | Window resize layout recalc | Debounce | | Real-time drag position | Throttle | | Button to prevent double-submit | Debounce |
Debouncing and throttling are small investments that prevent a class of performance and UX bugs in any interface that responds to high-frequency events. Write the useDebounce and useThrottle hooks once, add them to your project's utility layer, and reuse them across the codebase. For broader performance optimization strategies in React applications, see Understanding React Rendering and the Virtual DOM. If you're building out a project that uses these patterns, you can see real-world examples in the work on /projects.