useEffect looks simple. Pass it a function, throw in a dependency array, done. Except it's not done, because now your component is stuck in an infinite loop and your laptop sounds like a jet engine. This hook will humble 10-year veterans faster than a leetcode interview.
The Infinite Loop Trap: Dependency Array Mistakes
Want to turn your MacBook into a space heater? Stick an object in your useEffect dependency array. React looks at {name: 'John'}
and {name: 'John'}
and goes "these are totally different!" because JavaScript reference equality is a cruel joke. Your effect runs, creates a new object, React sees it's "different", runs the effect again, creates another new object... and now you're watching your CPU usage hit 100% while your component renders itself to death.
The Problem Pattern:
const [user, setUser] = useState({});
useEffect(() => {
setUser({ name: 'John', age: 30 });
}, [user]); // Creates infinite loop - new object reference each time
React uses Object.is() to compare dependencies, which works great for primitives but treats every object as unique. That {name: 'John', age: 30}
you're passing? React thinks it's a completely different object every time because it has a new memory address. React DevTools Profiler will show you just how many times your effect is running - spoiler alert: it's way too many.
The Fix:
- Use primitive values in dependency arrays:
[user.name, user.age]
(React docs example) - Employ `useMemo` or `useCallback` for object/function dependencies
- Use an empty array
[]
for one-time effects (with caution) (empty dependency pattern) - Use functional updates:
setUser(prevUser => ({ ...prevUser, name: 'John' }))
Stale Closures: When Variables Get Stuck in Time
Stale closures are when your useEffect grabs a variable value and then never lets go, like that friend who still quotes memes from 2019. Your state updates but your effect is still stuck with the old value, creating bugs that make you question your understanding of basic JavaScript.
The Problem:
function Timer() {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
console.log(count); // Always logs 0, never updates
setCount(count + 1); // Sets to 1, then 1, then 1...
}, 1000);
return () => clearInterval(timer);
}, []); // Missing count dependency creates stale closure
return <div>{count}</div>;
}
The closure inside setInterval
captures the initial count
value (0) and never sees updates, because the empty dependency array prevents re-running the effect.
Solutions:
- Include dependencies:
useEffect(() => { ... }, [count])
(exhaustive deps rule) - Use functional updates:
setCount(prev => prev + 1)
(functional state updates) - Use useRef for mutable values:
const countRef = useRef(count)
(useRef pattern)
Async Function Anti-Pattern: The Promise Problem
React's useEffect hates async functions. You try to make the callback async and React throws a fit because it's expecting either nothing back or a cleanup function, not a Promise. This trips up everyone coming from class components where you could just slap async on componentDidMount and call it a day.
Wrong Approach:
useEffect(async () => {
const data = await fetchUser(); // This breaks cleanup mechanisms
setUser(data);
}, []);
Correct Patterns:
useEffect(() => {
const fetchData = async () => {
try {
const data = await fetchUser();
setUser(data);
} catch (error) {
setError(error.message);
}
};
fetchData();
}, []);
Memory Leaks: The Cleanup Crisis
You know that warning "Can't perform a React state update on an unmounted component"? That's React's polite way of saying "your component died but your async function is still trying to update its corpse." This happens when your API call finishes after the user navigated away and your component got nuked.
The Problem:
useEffect(() => {
fetchUserData().then(user => {
setUser(user); // May run after component unmounts
});
}, []);
Robust Solution:
useEffect(() => {
let isMounted = true;
const fetchData = async () => {
try {
const user = await fetchUserData();
if (isMounted) {
setUser(user);
}
} catch (error) {
if (isMounted) {
setError(error);
}
}
};
fetchData();
return () => {
isMounted = false;
};
}, []);
Missing Dependencies: ESLint Warnings You Shouldn't Ignore
ESLint's exhaustive-deps rule is like that friend who points out every typo in your texts - annoying but usually right. Most devs see the warning and immediately add // eslint-disable-next-line
because fixing the actual problem feels harder than ignoring the warning. Don't be that dev.
Common Anti-Pattern:
useEffect(() => {
fetchUser(userId).then(setUser);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // Missing userId dependency
Ignoring this warning creates bugs when userId
changes but the effect doesn't re-run, leaving stale data displayed.
Proper Solution:
useEffect(() => {
const fetchData = async () => {
const user = await fetchUser(userId);
setUser(user);
};
fetchData();
}, [userId]); // Include all dependencies
These fundamental issues—infinite loops, stale closures, async handling, memory leaks, and missing dependencies—account for 90% of useEffect problems. Recognizing these patterns immediately narrows your debugging focus and leads to faster solutions.