Introduction to Component Lifecycle
Every React component goes through a series of stages during its existence, collectively known as the "component lifecycle." Understanding this lifecycle is crucial for controlling when certain operations happen in your application.
In class components, React provided explicit lifecycle methods like componentDidMount,
componentDidUpdate, and componentWillUnmount. In functional components,
we manage these lifecycle events using the useEffect Hook.
Side Effects in React
In React, a "side effect" is any operation that affects something outside the scope of the current function being executed. Common examples include:
- Data fetching from an API
- Setting up subscriptions or event listeners
- Manually changing the DOM
- Logging
- Setting timers
- Accessing browser APIs (localStorage, geolocation, etc.)
Real-world analogy: Think of a side effect like ordering food at a restaurant. The main function of your visit is to eat, but you need to perform the side effect of placing an order to make that happen. Similarly, your component's main job is to render UI, but it often needs to perform side effects to get or set up the data it needs.
The useEffect Hook
The useEffect Hook lets you perform side effects in functional components.
It serves the same purpose as componentDidMount, componentDidUpdate, and
componentWillUnmount in React class components, but unified into a single API.
Basic Syntax
import React, { useEffect } from 'react';
function MyComponent() {
useEffect(() => {
// Code to run after render
console.log('Component rendered');
// Optional cleanup function
return () => {
console.log('Component cleanup');
};
}, [/* dependency array */]);
return <div>My Component</div>;
}
The Dependency Array
The second argument to useEffect is an array of dependencies.
It controls when the effect runs:
| Dependency Array | Behavior | Equivalent Class Method |
|---|---|---|
| No dependency array | Effect runs after every render | componentDidMount + componentDidUpdate |
Empty array [] |
Effect runs only after the first render | componentDidMount |
Array with values [a, b] |
Effect runs after first render and when a or b change | componentDidMount + conditional componentDidUpdate |
useEffect for Component Mounting
To run an effect only when a component mounts (is first rendered), use an empty dependency array.
Example: Fetching Initial Data
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
// This effect runs once when the component mounts
async function fetchUser() {
try {
setLoading(true);
const response = await fetch(`https://api.example.com/users/${userId}`);
if (!response.ok) throw new Error('Failed to fetch user');
const data = await response.json();
setUser(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
fetchUser();
}, []); // Empty dependency array means "only run on mount"
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
if (!user) return null;
return (
<div>
<h2>{user.name}</h2>
<p>Email: {user.email}</p>
<p>Bio: {user.bio}</p>
</div>
);
}
Note: In this example, we actually have a dependency on userId,
which should be included in the dependency array. We'll address this in the next section.
Common Mount-Only Operations
- Initial data fetching
- Setting up third-party libraries or SDKs
- Registering global event listeners
- Connecting to external services
useEffect for Component Updates
To run an effect when specific values change (like props or state), include those values in the dependency array.
Example: Data Fetching on Prop Change
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
// This effect runs when the component mounts AND when userId changes
useEffect(() => {
let isMounted = true; // For cleanup - prevent state updates if unmounted
async function fetchUser() {
try {
setLoading(true);
const response = await fetch(`https://api.example.com/users/${userId}`);
if (!response.ok) throw new Error('Failed to fetch user');
const data = await response.json();
// Only update state if component is still mounted
if (isMounted) {
setUser(data);
setLoading(false);
}
} catch (err) {
if (isMounted) {
setError(err.message);
setLoading(false);
}
}
}
fetchUser();
// Cleanup function
return () => {
isMounted = false;
};
}, [userId]); // Dependency array with userId
// Rest of component remains the same
}
Real-world example: This pattern is similar to how social media profiles work. When you navigate from one user's profile to another, the UI updates to fetch and display the new user's information.
React 18+ Note:
In React 18+, setting state in unmounted components is no longer a memory leak issue, so the
isMounted approach above isn't strictly necessary. However, it's still a good practice
to avoid performing unnecessary operations after a component unmounts.
Handling Multiple Dependencies
function ProductList({ category, sortBy, page }) {
const [products, setProducts] = useState([]);
useEffect(() => {
fetchProducts(category, sortBy, page)
.then(data => setProducts(data));
}, [category, sortBy, page]); // Effect runs when any of these change
return (
<div>
{products.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}
useEffect for Cleanup (Unmounting)
The cleanup function (returned by your effect) runs before the component unmounts, and also before the effect runs again if it has dependencies that change.
Example: Event Listener Cleanup
function WindowSizeTracker() {
const [windowSize, setWindowSize] = useState({
width: window.innerWidth,
height: window.innerHeight
});
useEffect(() => {
// Define the event handler
function handleResize() {
setWindowSize({
width: window.innerWidth,
height: window.innerHeight
});
}
// Add event listener
window.addEventListener('resize', handleResize);
// Return cleanup function
return () => {
// Remove event listener on unmount
window.removeEventListener('resize', handleResize);
};
}, []); // Empty dependency array - only run on mount/unmount
return (
<div>
<p>Window width: {windowSize.width}px</p>
<p>Window height: {windowSize.height}px</p>
</div>
);
}
Real-world analogy: If you set up a subscription to a newspaper when you move into an apartment, you should cancel that subscription when you move out. The cleanup function is like telling the post office to stop delivering the newspaper to your old address.
Common Cleanup Operations
- Removing event listeners
- Clearing timers and intervals
- Cancelling network requests
- Closing WebSocket connections
- Unsubscribing from observable subscriptions
Effect Execution Timing
Important things to understand about effect timing:
- Effects run after the render is committed to the screen
- Effects are deferred until after the browser has painted
- Effects that update state will trigger another render cycle
Why this matters: Understanding the timing helps you avoid infinite loops and ensures your UI remains responsive. Since effects run after painting, they don't block the browser from updating the screen.
Multiple Effects
You can use multiple useEffect hooks in a single component to separate concerns.
function UserDashboard({ userId }) {
// State for user profile
const [user, setUser] = useState(null);
// State for activity feed
const [activities, setActivities] = useState([]);
// State for online status
const [isOnline, setIsOnline] = useState(navigator.onLine);
// Effect for fetching user data
useEffect(() => {
fetchUser(userId).then(data => setUser(data));
}, [userId]);
// Separate effect for fetching activity feed
useEffect(() => {
if (!user) return; // Only fetch activities if we have a user
fetchActivities(user.id).then(data => setActivities(data));
}, [user]);
// Separate effect for tracking online status
useEffect(() => {
function handleOnline() {
setIsOnline(true);
}
function handleOffline() {
setIsOnline(false);
}
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []); // Empty dependency array - only on mount/unmount
return (
<div>
{/* Dashboard UI */}
</div>
);
}
Why separate effects? It makes your code more maintainable by grouping related functionality. Each effect encapsulates a specific concern, making it easier to understand and modify.
Advanced useEffect Patterns
Debouncing
Debouncing is useful when you want to limit how often an effect runs, such as when handling user input.
function SearchComponent() {
const [searchTerm, setSearchTerm] = useState('');
const [results, setResults] = useState([]);
const [isSearching, setIsSearching] = useState(false);
// Debounced search effect
useEffect(() => {
if (!searchTerm) {
setResults([]);
return;
}
setIsSearching(true);
// Create a timeout to delay the search
const timeoutId = setTimeout(() => {
performSearch(searchTerm)
.then(data => {
setResults(data);
setIsSearching(false);
});
}, 500); // 500ms delay
// Clean up the timeout if searchTerm changes before timeout completes
return () => clearTimeout(timeoutId);
}, [searchTerm]);
return (
<div>
<input
type="text"
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
placeholder="Search..."
/>
{isSearching && <p>Searching...</p>}
<ul>
{results.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</div>
);
}
Real-world example: Think of typing in Google's search box. It doesn't send a request for every keystroke, but waits until you pause typing before fetching results.
Async Effect with Cleanup
Handling race conditions when dealing with asynchronous operations:
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
let isCurrent = true; // Flag to track if component is current
// Define async function inside effect
async function fetchUserData() {
try {
const response = await fetch(`https://api.example.com/users/${userId}`);
const userData = await response.json();
// Only update state if this is still the current render
if (isCurrent) {
setUser(userData);
}
} catch (error) {
if (isCurrent) {
console.error("Failed to fetch user:", error);
}
}
}
fetchUserData();
// Cleanup function sets flag to false when unmounting or re-running effect
return () => {
isCurrent = false;
};
}, [userId]);
// Rest of component...
}
This pattern is crucial when dealing with asynchronous operations that might complete after the component has unmounted or after a new effect has started.
Common useEffect Mistakes
Missing Dependencies
❌ Incorrect
function Counter({ initial }) {
const [count, setCount] = useState(initial);
// Missing dependency on 'initial'
useEffect(() => {
setCount(initial);
}, []); // Should include 'initial'
}
This effect won't run when the 'initial' prop changes.
✅ Correct
function Counter({ initial }) {
const [count, setCount] = useState(initial);
useEffect(() => {
setCount(initial);
}, [initial]); // Includes all dependencies
}
Infinite Loops
❌ Incorrect
function InfiniteLoop() {
const [count, setCount] = useState(0);
// This creates an infinite loop!
useEffect(() => {
setCount(count + 1);
}, [count]); // Effect updates count, triggering effect again
}
✅ Correct
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
// Only increment once when mounted
if (count === 0) {
setCount(1);
}
}, [count]);
// Or better, use a different approach:
useEffect(() => {
setCount(1); // Set initial value once
}, []); // Empty dependency array - run once on mount
}
Stale Closures
❌ Incorrect
function Timer() {
const [count, setCount] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
// This closure captures the initial value of count (0)
// and will always increment from that value
setCount(count + 1);
}, 1000);
return () => clearInterval(interval);
}, []); // Empty dependency array means interval uses stale 'count'
}
✅ Correct
function Timer() {
const [count, setCount] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
// Use functional update to get the latest state
setCount(prevCount => prevCount + 1);
}, 1000);
return () => clearInterval(interval);
}, []); // Empty dependency array is fine with functional updates
}
Real-World Applications
Form with Auto-Save
function AutoSaveForm() {
const [formData, setFormData] = useState({
title: '',
content: ''
});
const [isSaving, setIsSaving] = useState(false);
const [lastSaved, setLastSaved] = useState(null);
// Handle form changes
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
};
// Auto-save effect
useEffect(() => {
// Don't save if form is empty
if (!formData.title && !formData.content) return;
// Set a timeout for auto-save
const timeoutId = setTimeout(() => {
setIsSaving(true);
// Simulate API call
saveToServer(formData)
.then(() => {
setLastSaved(new Date());
setIsSaving(false);
})
.catch(error => {
console.error('Failed to save:', error);
setIsSaving(false);
});
}, 2000); // Auto-save 2 seconds after typing stops
// Clean up timeout if user continues typing
return () => clearTimeout(timeoutId);
}, [formData]);
return (
<form>
<div>
<label htmlFor="title">Title:</label>
<input
id="title"
name="title"
value={formData.title}
onChange={handleChange}
/>
</div>
<div>
<label htmlFor="content">Content:</label>
<textarea
id="content"
name="content"
value={formData.content}
onChange={handleChange}
rows="6"
/>
</div>
<div>
{isSaving ? (
<span>Saving...</span>
) : lastSaved ? (
<span>Last saved: {lastSaved.toLocaleTimeString()}</span>
) : null}
</div>
</form>
);
}
// Mock function for the example
function saveToServer(data) {
return new Promise(resolve => {
setTimeout(resolve, 500);
});
}
Geolocation Tracking
function LocationTracker() {
const [location, setLocation] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
// Check if geolocation is supported
if (!navigator.geolocation) {
setError('Geolocation is not supported by your browser');
return;
}
// Success handler
const handleSuccess = (position) => {
const { latitude, longitude } = position.coords;
setLocation({ latitude, longitude });
setError(null);
};
// Error handler
const handleError = (error) => {
setError(`Failed to get location: ${error.message}`);
};
// Options for geolocation
const options = {
enableHighAccuracy: true,
maximumAge: 30000,
timeout: 10000
};
// Watch position (continuous updates)
const watchId = navigator.geolocation.watchPosition(
handleSuccess,
handleError,
options
);
// Cleanup: stop watching position
return () => {
navigator.geolocation.clearWatch(watchId);
};
}, []); // Empty dependency array - only on mount/unmount
return (
<div>
{error ? (
<p>Error: {error}</p>
) : !location ? (
<p>Getting your location...</p>
) : (
<div>
<p>Your coordinates:</p>
<p>Latitude: {location.latitude}</p>
<p>Longitude: {location.longitude}</p>
<a
href={`https://maps.google.com/?q=${location.latitude},${location.longitude}`}
target="_blank"
rel="noopener noreferrer"
>
View on Google Maps
</a>
</div>
)}
</div>
);
}
WebSocket Chat Connection
function ChatRoom({ roomId, username }) {
const [messages, setMessages] = useState([]);
const [connected, setConnected] = useState(false);
const [inputMessage, setInputMessage] = useState('');
// WebSocket connection effect
useEffect(() => {
// Create WebSocket connection
const socket = new WebSocket('wss://example.com/chat');
// Connection opened
socket.addEventListener('open', () => {
setConnected(true);
// Join specific room
socket.send(JSON.stringify({
type: 'join',
roomId,
username
}));
});
// Listen for messages
socket.addEventListener('message', (event) => {
const message = JSON.parse(event.data);
if (message.type === 'chat') {
setMessages(prev => [...prev, message]);
}
});
// Connection closed or error
socket.addEventListener('close', () => {
setConnected(false);
});
socket.addEventListener('error', () => {
setConnected(false);
});
// Cleanup on unmount or when roomId changes
return () => {
// Send leave message if connected
if (socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({
type: 'leave',
roomId,
username
}));
}
// Close connection
socket.close();
};
}, [roomId, username]); // Re-connect if room or username changes
// Send message function
const sendMessage = (e) => {
e.preventDefault();
if (!inputMessage.trim() || !connected) return;
const socket = new WebSocket('wss://example.com/chat');
socket.send(JSON.stringify({
type: 'chat',
roomId,
username,
message: inputMessage
}));
setInputMessage('');
};
return (
<div className="chat-room">
<div className="status">
{connected ? 'Connected' : 'Disconnected'}
</div>
<div className="messages">
{messages.map((msg, index) => (
<div key={index} className={`message ${msg.username === username ? 'own' : ''}`}>
<strong>{msg.username}:</strong> {msg.message}
</div>
))}
</div>
<form onSubmit={sendMessage}>
<input
type="text"
value={inputMessage}
onChange={e => setInputMessage(e.target.value)}
placeholder="Type a message..."
disabled={!connected}
/>
<button type="submit" disabled={!connected}>Send</button>
</form>
</div>
);
}
Practice Exercises
Exercise 1: Data Fetching
Create a component that fetches data from the JSONPlaceholder API (https://jsonplaceholder.typicode.com/posts) and displays the results. Include:
- Loading state
- Error handling
- Proper cleanup for unmounted components
Exercise 2: Interval Timer
Build a timer component that updates every second and includes:
- Start/pause functionality
- Reset button
- Proper cleanup of intervals
Exercise 3: Form with Validation
Create a form that validates input as the user types with a slight delay (debounce) and:
- Shows validation errors after user stops typing
- Prevents submission if errors exist
- Submits data to console when valid
Summary
In this lecture, we've covered:
- The React component lifecycle and how it translates to hooks
- Understanding side effects in React components
- Using useEffect for component mounting, updating, and unmounting
- Properly handling dependencies in useEffect
- Implementing cleanup for subscriptions and async operations
- Advanced patterns like debouncing and race condition prevention
- Common mistakes and how to avoid them
- Real-world examples and applications of useEffect
The useEffect Hook is a powerful tool for handling side effects in React. By understanding when and how effects run, you can build components that interact cleanly with external systems while maintaining a good user experience.