Side Effects and useEffect Hook
Master the useEffect hook to handle side effects like data fetching, subscriptions, and DOM manipulation.
Topics Covered:
Prerequisites:
- Managing State with useState
Video Tutorial
Overview
Side effects are operations that affect something outside the component's render, like API calls, setting up subscriptions, or manually changing the DOM. React components are 'pure functions' - given the same props and state, they should always return the same JSX. But applications need to do things like fetch data, set up subscriptions, or update the DOM - these are 'side effects'. The useEffect hook is React's way of handling side effects in function components. Understanding useEffect is crucial for building real-world applications that interact with APIs, manage subscriptions, and handle cleanup.
Understanding Side Effects in React
In React, side effects are operations that interact with the outside world - anything that doesn't directly relate to rendering JSX. React components should be 'pure functions' that return JSX based on props and state, but real applications need to do more. Common Side Effects: • Fetching data from an API • Setting up subscriptions (websockets, event listeners) • Manually changing the DOM (updating document title, focusing inputs) • Starting/stopping timers • Logging analytics • Reading from/writing to localStorage Why useEffect? • Separates side effects from rendering logic • Runs after render, not during (won't block rendering) • Provides cleanup mechanism • Can control when effects run using dependencies
useEffect Basic Syntax
useEffect accepts two arguments: a function (the effect) and an optional dependency array. The effect function runs after the component renders to the screen.
import { useState, useEffect } from 'react';
function Counter() {
const [count, setCount] = useState(0);
// Effect runs after every render
useEffect(() => {
document.title = `Count: ${count}`;
});
// Effect runs only once (after mount)
useEffect(() => {
console.log('Component mounted');
}, []); // Empty dependency array
// Effect runs when count changes
useEffect(() => {
console.log('Count changed to:', count);
}, [count]); // List dependencies
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>+</button>
</div>
);
}The dependency array controls when the effect runs. No array = every render. Empty array [] = once after mount. Array with values = when those values change.
The Three Types of useEffect
There are three main patterns for useEffect, each serving different purposes: 1. Effect with no dependencies - runs after every render 2. Effect with empty dependencies - runs once after mount 3. Effect with dependencies - runs when dependencies change
// Pattern 1: Run after every render (rarely needed)
useEffect(() => {
console.log('Rendered');
// This runs after EVERY render
// Usually not what you want - can cause performance issues
});
// Pattern 2: Run once on mount (common for setup)
useEffect(() => {
// Fetch initial data
fetchUserData();
// Set up subscriptions
const subscription = subscribeToUpdates();
// Update document title once
document.title = 'My App';
// Cleanup on unmount
return () => {
subscription.unsubscribe();
};
}, []); // Empty array = run once
// Pattern 3: Run when dependencies change (most common)
useEffect(() => {
// Fetch data when userId changes
fetchUserPosts(userId);
// Update title when count changes
document.title = `Count: ${count}`;
}, [userId, count]); // List all dependenciesMost effects fall into pattern 2 or 3. Pattern 1 (no dependencies) is rarely needed and can cause infinite loops or performance issues if not careful.
Dependency Array Rules
The dependency array is crucial for useEffect. Understanding when to include dependencies and the rules around them prevents bugs. Key Rules: • Include ALL values from component scope used in the effect • Missing dependencies can cause stale closures • Extra dependencies cause unnecessary re-runs • Functions and objects should be wrapped in useCallback/useMemo if used as dependencies • ESLint's exhaustive-deps rule helps catch missing dependencies
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [showDetails, setShowDetails] = useState(false);
// ❌ MISSING DEPENDENCY - userId not in array
useEffect(() => {
fetchUser(userId); // Uses userId but not in deps
}, []); // Missing userId!
// ✅ CORRECT - includes userId
useEffect(() => {
fetchUser(userId);
}, [userId]); // userId is a dependency
// ❌ MISSING DEPENDENCY - function in effect
const formatUser = (user) => {
return user.name.toUpperCase();
};
useEffect(() => {
const formatted = formatUser(user); // Uses formatUser
}, [user]); // Missing formatUser!
// ✅ CORRECT - move function inside effect
useEffect(() => {
const formatUser = (user) => user.name.toUpperCase();
const formatted = formatUser(user);
}, [user]);
// ✅ OR wrap in useCallback
const formatUser = useCallback((user) => {
return user.name.toUpperCase();
}, []);
useEffect(() => {
const formatted = formatUser(user);
}, [user, formatUser]);
// Dependency on props/state
useEffect(() => {
if (showDetails) {
loadUserDetails(userId);
}
}, [userId, showDetails]); // Both are usedAlways include all values from component scope that the effect uses. Missing dependencies cause stale closures where the effect uses old values. The ESLint rule 'react-hooks/exhaustive-deps' helps catch these issues.
Cleanup Functions - Preventing Memory Leaks
Cleanup functions are essential for preventing memory leaks. They run before the component unmounts or before the effect runs again (if dependencies changed). Always clean up: • Timers (setInterval, setTimeout) • Subscriptions (websockets, event listeners) • Cancelled API requests • Event listeners added to DOM
// Timer cleanup
useEffect(() => {
const timer = setInterval(() => {
console.log('Tick');
}, 1000);
// Cleanup function
return () => {
clearInterval(timer);
};
}, []);
// Event listener cleanup
useEffect(() => {
const handleResize = () => {
console.log('Window resized');
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, []);
// Subscription cleanup
useEffect(() => {
const subscription = dataStream.subscribe(data => {
setData(data);
});
return () => {
subscription.unsubscribe();
};
}, []);
// Cleanup with dependencies - runs before re-running effect
useEffect(() => {
const controller = new AbortController();
fetch(`/api/users/${userId}`, {
signal: controller.signal
})
.then(res => res.json())
.then(setUser);
// Cleanup cancels request if userId changes or component unmounts
return () => {
controller.abort();
};
}, [userId]);Cleanup functions prevent memory leaks. They run before unmount AND before the effect runs again (if dependencies changed). Always clean up timers, subscriptions, and event listeners.
Data Fetching with useEffect
Fetching data is one of the most common use cases for useEffect. However, there are important patterns to follow to handle loading states, errors, and cleanup properly.
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
// Reset states when userId changes
setLoading(true);
setError(null);
setUser(null);
// AbortController for cleanup
const controller = new AbortController();
async function fetchUser() {
try {
const response = await fetch(
`/api/users/${userId}`,
{ signal: controller.signal }
);
if (!response.ok) {
throw new Error('Failed to fetch user');
}
const userData = await response.json();
setUser(userData);
} catch (err) {
// Don't set error if request was aborted
if (err.name !== 'AbortError') {
setError(err.message);
}
} finally {
setLoading(false);
}
}
fetchUser();
// Cleanup: abort request if userId changes or component unmounts
return () => {
controller.abort();
};
}, [userId]);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
if (!user) return null;
return <div>{user.name}</div>;
}Always handle loading and error states. Use AbortController to cancel requests when dependencies change or component unmounts. Reset states when fetching new data.
Common useEffect Mistakes
There are several common mistakes developers make with useEffect that can cause bugs, infinite loops, or performance issues.
// ❌ MISTAKE 1: Infinite loop - missing dependency
function BadCounter() {
const [count, setCount] = useState(0);
useEffect(() => {
setCount(count + 1); // Infinite loop!
}); // Missing dependency array
return <div>{count}</div>;
}
// ✅ CORRECT - Use dependency array or functional update
useEffect(() => {
// Only run once on mount
setCount(prev => prev + 1);
}, []);
// ❌ MISTAKE 2: Stale closure - object/array dependency
function BadEffect() {
const [items, setItems] = useState([]);
const filter = { category: 'electronics' };
useEffect(() => {
fetchItems(filter); // filter is a new object each render
}, [filter]); // filter changes every render!
}
// ✅ CORRECT - Use primitive values or memoize
useEffect(() => {
const filter = { category: 'electronics' };
fetchItems(filter);
}, []); // Or extract to state/useMemo
// ❌ MISTAKE 3: Not cleaning up
function BadTimer() {
useEffect(() => {
setInterval(() => {
console.log('Tick');
}, 1000);
// Missing cleanup - memory leak!
}, []);
}
// ✅ CORRECT - Always clean up
useEffect(() => {
const timer = setInterval(() => {
console.log('Tick');
}, 1000);
return () => clearInterval(timer);
}, []);
// ❌ MISTAKE 4: Effect that should be event handler
function BadForm() {
const [email, setEmail] = useState('');
useEffect(() => {
validateEmail(email); // Should be in onChange
}, [email]);
}
// ✅ CORRECT - Use event handler
function GoodForm() {
const [email, setEmail] = useState('');
const handleChange = (e) => {
setEmail(e.target.value);
validateEmail(e.target.value); // Directly in handler
};
}Common mistakes include infinite loops from missing dependencies, stale closures from object dependencies, not cleaning up resources, and using effects when event handlers are more appropriate.
When NOT to Use useEffect
Not everything needs useEffect! Many things can be handled differently, which is often cleaner and more performant. Don't use useEffect for: • Transforming data for rendering (do it during render) • Handling user events (use event handlers) • Resetting state on prop change (use key prop) • Computing derived state (calculate during render) • Initializing state (use useState initializer)
// ❌ BAD - Transforming data in effect
function UserList({ users }) {
const [filteredUsers, setFilteredUsers] = useState([]);
useEffect(() => {
setFilteredUsers(users.filter(u => u.active));
}, [users]); // Unnecessary effect
return <div>{/* render */}</div>;
}
// ✅ GOOD - Transform during render
function UserList({ users }) {
const filteredUsers = users.filter(u => u.active);
return <div>{/* render */}</div>;
}
// ❌ BAD - Event handling in effect
function SearchBox() {
const [query, setQuery] = useState('');
useEffect(() => {
if (query) {
search(query);
}
}, [query]); // Should be in onChange
return <input value={query} onChange={e => setQuery(e.target.value)} />;
}
// ✅ GOOD - Handle in event handler
function SearchBox() {
const [query, setQuery] = useState('');
const handleChange = (e) => {
const value = e.target.value;
setQuery(value);
if (value) {
search(value); // Directly in handler
}
};
return <input value={query} onChange={handleChange} />;
}
// ❌ BAD - Resetting state when prop changes
function UserProfile({ userId }) {
const [data, setData] = useState(null);
useEffect(() => {
setData(null); // Reset when userId changes
}, [userId]);
}
// ✅ GOOD - Use key prop to reset component
<UserProfile key={userId} userId={userId} />Many things that seem to need useEffect can be handled more simply. Transform data during render, handle events in event handlers, and use keys to reset components. Only use useEffect for true side effects.
Conclusion
useEffect is powerful but requires careful dependency management. Always include all dependencies to avoid bugs.