Introduction to Component Lifecycle
Every React component follows a lifecycle - it's born (mounted), grows and changes (updates), and eventually dies (unmounts). Understanding this lifecycle is crucial for managing side effects, optimizing performance, and creating responsive applications.
In class components, React provided explicit lifecycle methods like componentDidMount, componentDidUpdate, and componentWillUnmount.
In modern React with hooks, we use the useEffect hook to tap into these same lifecycle phases.
The useEffect Hook
useEffect is React's built-in hook for performing side effects in functional components. Side effects include:
- Data fetching from APIs
- Setting up subscriptions or event listeners
- Manually changing the DOM
- Logging
- Timers and intervals
The basic syntax of useEffect is:
useEffect(() => {
// This code runs after render
return () => {
// This cleanup code runs before the component unmounts
// or before the effect runs again
};
}, [dependencies]);
Think of useEffect as a way to synchronize your component with an external system. It's like telling React:
"After you're done updating the DOM, run this code."
Mental Model: useEffect as a Lifecycle Observer
Imagine your effect as a specialized observer that watches your component and reacts to specific moments in its lifecycle.
Different useEffect Patterns
Run Once (On Mount)
This pattern is equivalent to componentDidMount in class components.
useEffect(() => {
console.log('Component mounted');
// Fetch initial data
fetchUserData();
return () => {
console.log('Component will unmount');
// Cleanup when component unmounts
};
}, []); // Empty dependency array means "run once after first render"
Real-world example: Loading a user's profile data when they visit their dashboard.
Run on Every Render
useEffect(() => {
console.log('Component rendered');
// This runs after every render
}); // No dependency array
Real-world example: Logging renders during development to debug performance issues.
Run When Specific Values Change
useEffect(() => {
console.log('userId or search changed');
// Fetch data based on userId and search parameters
fetchSearchResults(userId, search);
return () => {
// Cancel any pending requests
cancelPendingRequests();
};
}, [userId, search]); // Only re-run if userId or search changes
Real-world example: Updating search results as a user types in a search box.
Effect Cleanup
The cleanup function (returned by your effect) is crucial for preventing memory leaks and unexpected behavior. It runs before the component unmounts and before the effect runs again if dependencies change.
Common things to clean up:
- Event listeners (
window.removeEventListener) - Subscriptions (unsubscribe from data sources)
- Timers (clearTimeout, clearInterval)
- WebSocket connections (close the connection)
- Pending API requests (cancel tokens)
Event Listener Example
useEffect(() => {
const handleResize = () => {
setWindowWidth(window.innerWidth);
};
// Add event listener
window.addEventListener('resize', handleResize);
// Cleanup function
return () => {
window.removeEventListener('resize', handleResize);
};
}, []);
Analogy: Think of cleanup like a responsible camper who follows the rule "leave no trace." Before moving to a new campsite or going home, you clean up everything you brought or changed.
Complex Example: Data Fetching with Loading States
Let's look at a more complex example that handles loading states, errors, and cleanup for API requests.
import React, { useState, useEffect } from 'react';
import axios from 'axios';
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);
// Create a cancel token for cleanup
const cancelToken = axios.CancelToken.source();
// Fetch user data
const fetchUser = async () => {
try {
const response = await axios.get(
`https://api.example.com/users/${userId}`,
{ cancelToken: cancelToken.token }
);
setUser(response.data);
setLoading(false);
} catch (err) {
if (axios.isCancel(err)) {
console.log('Request canceled:', err.message);
} else {
setError('Failed to fetch user data');
setLoading(false);
}
}
};
fetchUser();
// Cleanup function
return () => {
// Cancel the request if component unmounts or userId changes
cancelToken.cancel('Component unmounted or userId changed');
};
}, [userId]); // Re-run when userId changes
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
if (!user) return <div>No user found</div>;
return (
<div className="user-profile">
<h2>{user.name}</h2>
<p>Email: {user.email}</p>
<p>Location: {user.location}</p>
</div>
);
}
Real-world application: A social media profile page that loads user data when you navigate to it and updates when you switch between different users' profiles.
Understanding the Dependency Array
The dependency array is crucial for controlling when your effects run. Think of it as a list of "triggers" that will cause your effect to re-execute.
| Dependency Array | Behavior | Use Case |
|---|---|---|
[] (empty array) |
Runs once after first render (mount) | Initial data fetching, one-time setup |
[prop1, state2] |
Runs when any dependency changes | Reacting to specific data changes |
| No dependency array | Runs after every render | Rare; use with caution (can cause performance issues) |
Common Mistakes with useEffect
Missing Dependencies
A common mistake is not including all variables your effect uses in the dependency array. This can lead to stale closures and outdated values.
Incorrect ❌
function SearchComponent({ query, userId }) {
const [results, setResults] = useState([]);
useEffect(() => {
// This will use the INITIAL value of userId due to closure
fetchResults(query, userId).then(data => setResults(data));
}, [query]); // Missing userId in dependencies
}
Correct ✅
function SearchComponent({ query, userId }) {
const [results, setResults] = useState([]);
useEffect(() => {
fetchResults(query, userId).then(data => setResults(data));
}, [query, userId]); // Includes all dependencies
}
Infinite Loops
If your effect updates a state variable that it also depends on, you'll create an infinite loop.
Infinite Loop Example ❌
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
// This creates an infinite loop!
setCount(count + 1);
}, [count]); // Effect depends on count and also updates it
}
Object and Array Dependencies
JavaScript compares objects and arrays by reference, not by value. This can cause effects to run more often than needed.
Problem ❌
function ProfileCard({ user }) {
// Options object created on each render (new reference)
const options = { showDetails: true };
useEffect(() => {
fetchUserDetails(user.id, options);
}, [user.id, options]); // options is a new object each render
}
Solution ✅
function ProfileCard({ user }) {
// Move options outside the effect or use useMemo
const options = useMemo(() => (
{ showDetails: true }
), []); // Stable reference
useEffect(() => {
fetchUserDetails(user.id, options);
}, [user.id, options]); // options reference is stable
}
Mapping Class Lifecycle Methods to useEffect
For those familiar with class components, here's how the traditional lifecycle methods map to useEffect:
| Class Lifecycle Method | useEffect Equivalent |
|---|---|
componentDidMount |
useEffect(() => { ... }, []) |
componentDidUpdate |
useEffect(() => { ... }, [prop1, prop2]) |
componentWillUnmount |
useEffect(() => { return () => { ... } }, []) |
Advanced Effect Patterns
Debouncing with useEffect
A common pattern is debouncing user input, where you delay processing until the user stops typing.
function SearchBar() {
const [query, setQuery] = useState('');
const [debouncedQuery, setDebouncedQuery] = useState('');
// Update debouncedQuery after user stops typing for 500ms
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedQuery(query);
}, 500);
return () => {
clearTimeout(timer);
};
}, [query]);
// Use debouncedQuery for API calls
useEffect(() => {
if (debouncedQuery) {
searchAPI(debouncedQuery);
}
}, [debouncedQuery]);
return (
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
/>
);
}
Real-world application: A search box that doesn't overwhelm your API with requests while the user is typing.
Coordinating Multiple Effects
For complex components, you might need multiple effects that work together.
function UserDashboard({ userId }) {
// Effect 1: Load user profile
useEffect(() => {
fetchUserProfile(userId);
}, [userId]);
// Effect 2: Load user activity feed
useEffect(() => {
fetchUserActivity(userId);
}, [userId]);
// Effect 3: Set up notifications (WebSocket)
useEffect(() => {
const socket = connectToNotifications(userId);
return () => {
socket.disconnect();
};
}, [userId]);
}
Splitting effects by concern makes your code more maintainable and easier to understand.
Practical Applications and Examples
Form Validation with useEffect
function RegistrationForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [errors, setErrors] = useState({});
// Validate email when it changes
useEffect(() => {
if (!email) {
setErrors(prev => ({ ...prev, email: null }));
} else if (!/\S+@\S+\.\S+/.test(email)) {
setErrors(prev => ({ ...prev, email: 'Invalid email address' }));
} else {
setErrors(prev => ({ ...prev, email: null }));
}
}, [email]);
// Validate password when it changes
useEffect(() => {
if (!password) {
setErrors(prev => ({ ...prev, password: null }));
} else if (password.length < 8) {
setErrors(prev => ({ ...prev, password: 'Password must be at least 8 characters' }));
} else {
setErrors(prev => ({ ...prev, password: null }));
}
}, [password]);
return (
<form>
<div>
<label>Email:</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
{errors.email && <span className="error">{errors.email}</span>}
</div>
<div>
<label>Password:</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
{errors.password && <span className="error">{errors.password}</span>}
</div>
</form>
);
}
Persisting State with localStorage
function ThemeToggle() {
// Initialize state from localStorage
const [darkMode, setDarkMode] = useState(() => {
const saved = localStorage.getItem('darkMode');
return saved ? JSON.parse(saved) : false;
});
// Persist state changes to localStorage
useEffect(() => {
localStorage.setItem('darkMode', JSON.stringify(darkMode));
// Apply theme to document
document.body.className = darkMode ? 'dark-theme' : 'light-theme';
}, [darkMode]);
return (
<button onClick={() => setDarkMode(!darkMode)}>
Toggle {darkMode ? 'Light' : 'Dark'} Mode
</button>
);
}
Managing Document Title
function PageWithDynamicTitle({ title, userId }) {
// Update document title when component mounts/updates
useEffect(() => {
const originalTitle = document.title;
document.title = title ? `${title} | My App` : 'My App';
// Restore original title when component unmounts
return () => {
document.title = originalTitle;
};
}, [title]);
return (
<div>
<h1>{title}</h1>
{/* Component content */}
</div>
);
}
Best Practices for useEffect
- Keep effects focused on a single concern - Split effects by what they do rather than when they run. This makes your code more maintainable.
- Always include proper cleanup - This prevents memory leaks and unexpected behavior.
- Avoid unnecessary dependencies - Move function declarations inside the effect if they're only used there.
-
Use the ESLint rules for hooks - The
eslint-plugin-react-hookspackage helps catch common mistakes. - Consider custom hooks for reusable effects - Extract complex effect logic into custom hooks for reuse.
Custom Hook Example
// Custom hook for API data fetching
function useApi(url, dependencies = []) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
setError(null);
const controller = new AbortController();
const signal = controller.signal;
fetch(url, { signal })
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
})
.then(data => {
setData(data);
setLoading(false);
})
.catch(err => {
if (err.name === 'AbortError') {
console.log('Fetch aborted');
} else {
setError(err.message);
setLoading(false);
}
});
return () => {
controller.abort();
};
}, [url, ...dependencies]);
return { data, loading, error };
}
// Usage
function UserProfile({ userId }) {
const { data: user, loading, error } = useApi(
`https://api.example.com/users/${userId}`,
[userId]
);
// Component rendering logic
}
Class Components vs. Hooks: A Comparison
| Aspect | Class Components | Hooks (useEffect) |
|---|---|---|
| Code Organization | Lifecycle methods spread logic across methods | Effects group related logic together |
| Mental Model | Based on when in lifecycle | Based on what needs to synchronize |
| Cleanup | In componentWillUnmount | Return function from useEffect |
| Reusing Logic | Complex (HOCs, render props) | Simple (custom hooks) |
| Readability | Complex effects spread across methods | Related logic stays together |
Real-world analogy: Class components are like organizing your home by when you use things (morning routine items, evening routine items). Hooks are like organizing by purpose (kitchen items, bathroom items, etc.) regardless of when you use them.
Practice Activities
Activity 1: Data Fetching Component
Create a component that fetches a list of posts from the JSONPlaceholder API and displays them. Implement loading states, error handling, and proper cleanup.
// API endpoint: https://jsonplaceholder.typicode.com/posts
Activity 2: Window Event Handler
Create a component that tracks the window size and updates when the window is resized. Make sure to properly remove the event listener when the component unmounts.
Activity 3: Custom useLocalStorage Hook
Create a custom hook called useLocalStorage that acts like useState
but persists the value to localStorage. Test it with a simple form component.
Activity 4: Debounced Search
Implement a search component that debounces user input and makes API requests only after the user stops typing for 500ms. Use the JSONPlaceholder API for testing.
// API endpoint: https://jsonplaceholder.typicode.com/posts?title_like=YOUR_QUERY
Summary
- Component Lifecycle: Every React component goes through mount, update, and unmount phases.
- useEffect: The primary hook for handling side effects and lifecycle events in function components.
- Dependency Array: Controls when effects run - empty for "mount only", specific dependencies for updates.
- Cleanup Function: Returns from useEffect to prevent memory leaks and clean up resources.
- Common Patterns: Data fetching, event listeners, localStorage persistence, document modifications.
- Best Practices: Keep effects focused, include proper cleanup, avoid unnecessary dependencies.
Understanding the component lifecycle and mastering useEffect are fundamental skills
for creating robust React applications that properly manage resources and synchronize with external systems.