React useEffect: Dependencies, Cleanup, and Common Pitfalls
useEffect is the most misunderstood React hook. Here's a clear guide to dependencies, cleanup, and the pitfalls that cause bugs.
useEffect fundamentals
useEffect runs side effects after every render, or conditionally based on dependencies.
import { useEffect } from 'react';
// Runs after EVERY render (no dependency array)
useEffect(() => {
document.title = `Count: ${count}`;
});
// Runs ONCE after mount (empty dependency array)
useEffect(() => {
fetchData();
}, []);
// Runs when count changes
useEffect(() => {
console.log('count changed to', count);
}, [count]);Cleanup functions
Return a function from useEffect to clean up subscriptions, timers, and event listeners.
useEffect(() => {
const handler = () => console.log('scrolled');
window.addEventListener('scroll', handler);
// Cleanup: runs before the effect re-runs AND when component unmounts
return () => {
window.removeEventListener('scroll', handler);
};
}, []);
// Timer cleanup
useEffect(() => {
const id = setInterval(() => setCount(c => c + 1), 1000);
return () => clearInterval(id);
}, []);Fetching data in useEffect
Data fetching is the most common useEffect use case. Handle loading and error states properly.
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false; // prevent stale updates
async function load() {
try {
setLoading(true);
const res = await fetch(`/api/users/${userId}`);
const data = await res.json();
if (!cancelled) setUser(data);
} catch (e) {
if (!cancelled) setError(e.message);
} finally {
if (!cancelled) setLoading(false);
}
}
load();
return () => { cancelled = true; };
}, [userId]); // re-fetch when userId changes
}The infinite loop trap
Missing or incorrect dependencies cause useEffect to run in an infinite loop.
// INFINITE LOOP: data is created on every render
// and changes reference each time
useEffect(() => {
setUser(data);
}, [data]); // data is a new object every render
// INFINITE LOOP: setting state that's in the dependency array
useEffect(() => {
setCount(count + 1); // triggers re-render
}, [count]); // which triggers effect again
// FIX: use functional update to remove count from deps
useEffect(() => {
setCount(prev => prev + 1); // no need for count in deps
}, []);Exam tip
The most common useEffect interview question: "Why does this cause an infinite loop?" — usually because the dependency array contains a value that's recreated on every render (like an inline object or function), or because the effect sets state that's also in the deps array.
Think you're ready? Prove it.
Take the free React readiness test. Get a score, topic breakdown, and your exact weak areas.
Take the free React test →Free · No sign-up · Instant results