Introduction
Last week, I was debugging a React app where data fetching was happening at the wrong time, causing flickering and stale data. The culprit? Misunderstanding when and how useEffect runs. After three years of React development, I've learned that useEffect is your go-to hook when you need to:
- Fetch data from APIs after component renders
- Update document titles or meta tags
- Set up subscriptions or event listeners
- Perform cleanup when components unmount
Think of useEffect as React's way of saying "hey, the component is ready—now you can do the messy stuff."
What Exactly Are Side Effects?
In React terms, side effects are operations that reach outside your component's rendering logic. They're the things that:
- Interact with external systems (APIs, localStorage, DOM)
- Aren't predictable or "pure" (network requests, timers)
- Can cause rendering issues if done during render
I learned this the hard way when I tried to fetch data directly in a component body—it caused infinite re-renders because each fetch triggered a state update, which triggered another render, which triggered another fetch.
The useEffect Anatomy
useEffect(() => {
// Effect logic runs after render
console.log('Component rendered');
return () => {
// Cleanup runs before next effect or unmount
console.log('Cleaning up');
};
}, [dependencies]); // Controls when effect runs
Here's what's happening:
- Effect function: Runs after every render (unless dependencies prevent it)
- Cleanup function: Optional, runs before the next effect or component unmount
- Dependencies array: Tells React when to re-run the effect
Real-World Example: Data Fetching
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchUser = async () => {
try {
const response = await fetch('/api/user');
const userData = await response.json();
setUser(userData);
} catch (error) {
console.error('Failed to fetch user:', error);
} finally {
setLoading(false);
}
};
fetchUser();
}, []); // Empty deps = run once on mount
This pattern handles the three states every data fetch needs: loading, success, and error. The empty dependency array ensures it only runs once when the component mounts.
Cleanup: Preventing Memory Leaks
useEffect(() => {
const handleResize = () => {
setWindowWidth(window.innerWidth);
};
window.addEventListener('resize', handleResize);
// This cleanup is crucial
return () => {
window.removeEventListener('resize', handleResize);
};
}, []);
I once shipped a feature without proper cleanup and discovered event listeners were accumulating on every route change. The app got slower and slower until users started complaining. Always clean up your subscriptions, timers, and event listeners.
Dependency Array: The Control Center
| Dependency Pattern | When Effect Runs | Use Case |
|---|---|---|
[] |
Once on mount | Initial data fetch, one-time setup |
[userId] |
When userId changes | Fetch user data when ID changes |
[a, b, c] |
When any value changes | Complex calculations, derived data |
| (no array) | Every render | Rarely needed, usually a mistake |
The Stale Closure Trap
// Problem: count is stale
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
console.log(count); // Always logs 0!
setCount(count + 1); // Creates infinite loop
}, 1000);
return () => clearInterval(timer);
}, []); // Empty deps cause stale closure
// Solution: Use functional updates
useEffect(() => {
const timer = setInterval(() => {
setCount(prev => prev + 1); // Always gets latest value
}, 1000);
return () => clearInterval(timer);
}, []);
This tripped me up for months when I was learning React. The effect captures the count value from when it was created, not the current value. Functional updates solve this elegantly.
useEffect vs Class Component Lifecycle
| Class Lifecycle | useEffect Equivalent | Key Difference |
|---|---|---|
componentDidMount |
useEffect(() => {}, []) |
Runs after first render |
componentDidUpdate |
useEffect(() => {}, [deps]) |
Runs when dependencies change |
componentWillUnmount |
return () => {} |
Cleanup function |
The beauty of useEffect is that it combines all three lifecycle methods into one API. You're not thinking about specific lifecycle moments—you're thinking about synchronization with external systems.
Common Pitfalls I've Encountered
| Mistake | Symptom | Solution |
|---|---|---|
| Missing dependencies | Stale data, ESLint warnings | Include all referenced values |
| Forgetting cleanup | Memory leaks, duplicate listeners | Always return cleanup function |
| Infinite loops | Browser freezes, API spam | Check dependencies, use functional updates |
| Async effect function | React warnings, race conditions | Define async function inside effect |
Production-Ready Patterns
Debounced Search
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
useEffect(() => {
if (!query.trim()) {
setResults([]);
return;
}
const timeoutId = setTimeout(async () => {
try {
const response = await fetch(`/api/search?q=${query}`);
const data = await response.json();
setResults(data);
} catch (error) {
console.error('Search failed:', error);
}
}, 300);
return () => clearTimeout(timeoutId);
}, [query]);
Intersection Observer for Performance
const [isVisible, setIsVisible] = useState(false);
const elementRef = useRef(null);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => setIsVisible(entry.isIntersecting),
{ threshold: 0.1 }
);
if (elementRef.current) {
observer.observe(elementRef.current);
}
return () => observer.disconnect();
}, []);
These patterns handle real performance concerns I've faced in production apps. The search debounce prevents API spam, and the intersection observer enables lazy loading.
useEffect Quick Reference
| Concept | Key Point |
|---|---|
| Timing | Runs after DOM updates, before paint |
| Dependencies | Control when effect re-runs |
| Cleanup | Prevents memory leaks and stale closures |
| Multiple effects | Separate concerns, run in order |
| Async operations | Define async functions inside effect |
Frequently Asked Questions
When exactly does useEffect run?
After the component renders and the DOM is updated, but before the browser paints. This timing ensures you can safely read DOM properties and make additional changes without causing visual flicker.
Why does my effect run twice in development?
React's StrictMode intentionally double-invokes effects to help catch bugs. Your effect should be resilient to this—if it breaks, you likely have a missing cleanup or dependency issue.
Can I make the effect function async?
No, the effect function itself can't be async because React expects either nothing or a cleanup function to be returned. Instead, define an async function inside the effect:
useEffect(() => {
const fetchData = async () => {
const response = await fetch('/api/data');
const data = await response.json();
setData(data);
};
fetchData();
}, []);
What happens if I don't provide a cleanup function?
For simple effects like data fetching, you might not need cleanup. But for subscriptions, timers, or event listeners, skipping cleanup leads to memory leaks and unexpected behavior.
Should I use multiple useEffect hooks?
Yes! I prefer separating concerns with multiple effects. One for data fetching, another for event listeners, etc. This makes the code more readable and easier to debug.