Managing Dependency Arrays in useEffect to Prevent Infinite Loops
A common mistake with React’s useEffect
hook is forgetting or incorrectly specifying the dependency array, which can lead to infinite re-renders or stale closures.
The Core Problem
// ❌ Missing dependency array - runs after every render
useEffect(() => {
fetchData();
});
// ❌ Incorrect dependencies - infinite loop
useEffect(() => {
setCount(count + 1);
}, [count]); // Updates count, which triggers effect again
Proper Usage Patterns
1. Empty Dependency Array (Mount Only)
// ✅ Runs only once when component mounts
useEffect(() => {
fetchInitialData();
}, []);
2. Complete Dependencies
// ✅ Runs when userId or page changes
useEffect(() => {
fetchUserData(userId, page);
}, [userId, page]); // All dependencies declared
3. Functional Updates to Avoid Dependencies
// ✅ No count in dependencies
useEffect(() => {
const interval = setInterval(() => {
setCount(prev => prev + 1); // Functional update
}, 1000);
return () => clearInterval(interval);
}, []); // Empty array = runs once on mount
Common Scenarios and Fixes
1. Fetching Data
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
// ✅ Correct dependencies
useEffect(() => {
const loadUser = async () => {
const data = await fetchUser(userId);
setUser(data);
};
loadUser();
}, [userId]); // Re-runs only when userId changes
}
2. Event Listeners
function ScrollWatcher() {
// ✅ Cleanup function with stable handler
useEffect(() => {
const handleScroll = throttle(() => {
console.log(window.scrollY);
}, 100);
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []); // Empty array = setup/cleanup once
}
3. Working with Objects/Dependencies
function Chat({ currentRoom }) {
const [messages, setMessages] = useState([]);
const room = { id: currentRoom, name: `Room ${currentRoom}` };
// ❌ Problem - new object every render
useEffect(() => {
subscribeToRoom(room);
return () => unsubscribeFromRoom(room);
}, [room]); // Infinite loop - room object changes
// ✅ Solution 1 - use primitive values
useEffect(() => {
subscribeToRoom(currentRoom);
return () => unsubscribeFromRoom(currentRoom);
}, [currentRoom]);
// ✅ Solution 2 - memoize object
const stableRoom = useMemo(() => ({
id: currentRoom,
name: `Room ${currentRoom}`
}), [currentRoom]);
}
Best Practices
- Always include all dependencies that your effect uses
- Use the exhaustive-deps ESLint rule to catch missing dependencies
- Split complex effects into multiple smaller effects
- Memoize dependencies with
useMemo
when passing objects/arrays - Use functional updates for state setters to avoid dependencies
- Move functions inside effects if they don’t need to be reused
Debugging Infinite Loops
If your effect runs in an infinite loop:
- Check if any dependency changes on every render
- Verify you’re not updating a dependency within the effect
- Consider if you need the effect at all (could the computation happen during render?)
- Use console.log to track dependency changes:
useEffect(() => {
console.log('Dependencies changed:', userId, page);
fetchData(userId, page);
}, [userId, page]);
Remember that the dependency array is how React knows when to re-run your effect. Getting it right prevents bugs while maintaining optimal performance.