Introduction to Side Effects in React
In React components, side effects are operations that interact with the "outside world" or affect something beyond the component's scope. These include data fetching, subscriptions, manual DOM manipulations, logging, and more.
Real-world analogy: Think of side effects like errands you need to run while making dinner. Your main job is cooking (rendering UI), but you occasionally need to step away to check the mail, answer the door, or make a phone call (side effects). These activities aren't directly part of cooking, but they're necessary parts of your overall household management.
Why We Need a Special Hook for Side Effects
React's rendering process should be pure and predictable - the same props and state should always produce the same output. Side effects break this purity because they interact with external systems.
The useEffect Hook provides a designated place to perform side effects,
keeping them separate from the rendering logic and ensuring they run at the appropriate times.
The useEffect Hook: Basic Syntax
The useEffect Hook takes two arguments:
- A function that contains the side effect code
- An optional dependency array that controls when the effect runs
import React, { useState, useEffect } from 'react';
function ExampleComponent() {
// State declaration
const [count, setCount] = useState(0);
// Effect declaration
useEffect(() => {
// Side effect code
document.title = `You clicked ${count} times`;
// Optional cleanup function
return () => {
document.title = 'React App';
};
}, [count]); // Dependency array
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
</div>
);
}
When Does useEffect Run?
The timing of useEffect execution depends on the dependency array:
| Dependency Array | When the Effect Runs | Use Case |
|---|---|---|
useEffect(() => {...})(no dependency array) |
After every render | Effects that need to run on every update |
useEffect(() => {...}, [])(empty array) |
Only after the initial render | Setup/initialization code, equivalent to componentDidMount |
useEffect(() => {...}, [a, b])(with dependencies) |
After initial render and when any dependency changes | Effects that depend on specific props or state |
Effect Cleanup: Preventing Memory Leaks
Some effects create resources or subscriptions that need to be cleaned up before the component is unmounted or before the effect runs again. You can return a cleanup function from your effect to handle this.
function WindowSizeTracker() {
const [windowSize, setWindowSize] = useState({
width: window.innerWidth,
height: window.innerHeight
});
useEffect(() => {
// Setup effect: Add event listener
const handleResize = () => {
setWindowSize({
width: window.innerWidth,
height: window.innerHeight
});
};
window.addEventListener('resize', handleResize);
// Cleanup function: Remove event listener
return () => {
window.removeEventListener('resize', handleResize);
};
}, []); // Empty array means run once on mount
return (
<div>
<h3>Window Size:</h3>
<p>Width: {windowSize.width}px</p>
<p>Height: {windowSize.height}px</p>
</div>
);
}
When Does Cleanup Run?
The cleanup function runs:
- Before the component unmounts
- Before the effect runs again (if dependencies change)
Real-world analogy: If setting up an effect is like turning on a machine, the cleanup function is like turning it off when you're done. Just as you'd turn off the lights when leaving a room to save energy, you clean up effects to prevent memory leaks and unexpected behavior.
Common Resources That Need Cleanup
- Event listeners
- Subscriptions (WebSockets, observables)
- Timers (setTimeout, setInterval)
- External library instances
- Connections to external services
useEffect for Data Fetching
One of the most common use cases for useEffect is fetching data from an API.
Basic Data Fetching Pattern
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);
// Define async function inside effect
async function fetchUser() {
try {
const response = await fetch(`https://api.example.com/users/${userId}`);
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const data = await response.json();
setUser(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
// Call the function
fetchUser();
// No cleanup needed for a simple fetch
}, [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>Username: {user.username}</p>
{/* Display more user data */}
</div>
);
}
Handling Race Conditions in Data Fetching
When fetching data based on changing props, you might encounter race conditions where an older fetch request completes after a newer one, causing stale data to override current data.
function SearchResults({ query }) {
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
// Skip empty queries
if (!query.trim()) {
setResults([]);
return;
}
let isMounted = true; // Flag to track if component is mounted
setLoading(true);
async function fetchResults() {
try {
const response = await fetch(
`https://api.example.com/search?q=${encodeURIComponent(query)}`
);
const data = await response.json();
// Only update state if component is still mounted
// and this effect is still current
if (isMounted) {
setResults(data.results);
setLoading(false);
}
} catch (error) {
// Only update state if component is still mounted
if (isMounted) {
console.error('Search error:', error);
setResults([]);
setLoading(false);
}
}
}
fetchResults();
// Cleanup function that runs when the component unmounts
// or before the effect runs again
return () => {
isMounted = false;
};
}, [query]);
return (
<div>
{loading ? (
<p>Loading results for "{query}"...</p>
) : (
<div>
<h3>Results for "{query}"</h3>
{results.length === 0 ? (
<p>No results found</p>
) : (
<ul>
{results.map(item => (
<li key={item.id}>{item.title}</li>
))}
</ul>
)}
</div>
)}
</div>
);
}
Note about React 18+: In React 18 and newer versions, state updates on unmounted components don't cause memory leaks or warnings anymore. However, it's still good practice to prevent unnecessary state updates, and the cleanup pattern is essential for proper cleanup of subscriptions, event listeners, etc.
Managing Timers with useEffect
useEffect is perfect for setting up and clearing timers in React components.
Simple Timer Example
function Timer() {
const [seconds, setSeconds] = useState(0);
const [isRunning, setIsRunning] = useState(false);
useEffect(() => {
// Only set up the timer if isRunning is true
if (!isRunning) return;
// Set up the interval
const intervalId = setInterval(() => {
setSeconds(prevSeconds => prevSeconds + 1);
}, 1000);
// Clean up the interval when the effect is re-run or the component unmounts
return () => {
clearInterval(intervalId);
};
}, [isRunning]); // Re-run when isRunning changes
const handleStartStop = () => {
setIsRunning(prevIsRunning => !prevIsRunning);
};
const handleReset = () => {
setSeconds(0);
setIsRunning(false);
};
// Format seconds as mm:ss
const formatTime = () => {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
};
return (
<div className="timer">
<div className="timer-display">{formatTime()}</div>
<div className="timer-controls">
<button onClick={handleStartStop}>
{isRunning ? 'Pause' : 'Start'}
</button>
<button onClick={handleReset}>
Reset
</button>
</div>
</div>
);
}
Debouncing with useEffect
Debouncing is a technique to limit how often a function is called. It's useful for search inputs, window resizing, and other events that can fire rapidly.
function DebouncedSearch() {
const [searchTerm, setSearchTerm] = useState('');
const [debouncedTerm, setDebouncedTerm] = useState('');
const [results, setResults] = useState([]);
const [isSearching, setIsSearching] = useState(false);
// Update searchTerm as the user types
const handleChange = (e) => {
setSearchTerm(e.target.value);
};
// Debounce the search term
useEffect(() => {
// Set a timeout to update the debounced term
const timeoutId = setTimeout(() => {
setDebouncedTerm(searchTerm);
}, 500); // 500ms delay
// Clean up the timeout if searchTerm changes before timeout completes
return () => {
clearTimeout(timeoutId);
};
}, [searchTerm]);
// Perform the search when debouncedTerm changes
useEffect(() => {
// Skip empty searches
if (!debouncedTerm.trim()) {
setResults([]);
return;
}
// Set loading state
setIsSearching(true);
// Define async search function
async function fetchResults() {
try {
const response = await fetch(
`https://api.example.com/search?q=${encodeURIComponent(debouncedTerm)}`
);
const data = await response.json();
setResults(data.results);
} catch (error) {
console.error('Search failed:', error);
setResults([]);
} finally {
setIsSearching(false);
}
}
// Execute the search
fetchResults();
}, [debouncedTerm]);
return (
<div>
<input
type="text"
value={searchTerm}
onChange={handleChange}
placeholder="Type to search..."
/>
{isSearching && <p>Searching...</p>}
<ul>
{results.map(item => (
<li key={item.id}>{item.title}</li>
))}
</ul>
{!isSearching && debouncedTerm && results.length === 0 && (
<p>No results found for "{debouncedTerm}"</p>
)}
</div>
);
}
useEffect for DOM Manipulation
While React generally handles DOM updates, sometimes you need direct DOM manipulation for specific use cases like focusing elements, measuring dimensions, or integrating with third-party libraries.
Auto-Focus Input Example
function AutoFocusInput() {
const inputRef = useRef(null);
// Focus the input when the component mounts
useEffect(() => {
if (inputRef.current) {
inputRef.current.focus();
}
}, []); // Empty dependency array means this runs once on mount
return (
<div>
<label htmlFor="auto-focus-input">This input will focus automatically:</label>
<input
id="auto-focus-input"
ref={inputRef}
type="text"
/>
</div>
);
}
Measuring DOM Elements
function ElementSize() {
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
const elementRef = useRef(null);
// Measure the element after it's rendered
useEffect(() => {
if (!elementRef.current) return;
// Create a ResizeObserver to track size changes
const resizeObserver = new ResizeObserver(entries => {
const { width, height } = entries[0].contentRect;
setDimensions({ width, height });
});
// Start observing our element
resizeObserver.observe(elementRef.current);
// Clean up observer on unmount
return () => {
resizeObserver.disconnect();
};
}, []); // Empty dependency array - run once on mount
return (
<div>
<div
ref={elementRef}
style={{
width: '50%',
minHeight: '100px',
padding: '20px',
background: '#f0f0f0',
resize: 'both',
overflow: 'auto',
border: '1px solid #ccc'
}}
>
<p>This element is resizable. Try resizing it!</p>
</div>
<p>Element dimensions: {dimensions.width.toFixed(0)}px × {dimensions.height.toFixed(0)}px</p>
</div>
);
}
Integrating with Third-Party Libraries
function ChartComponent({ data }) {
const chartRef = useRef(null);
const chartInstance = useRef(null);
// Initialize the chart when the component mounts
// or when data changes
useEffect(() => {
if (!chartRef.current) return;
// Clean up any existing chart
if (chartInstance.current) {
chartInstance.current.destroy();
}
// Create a new chart using a fictional Chart library
const ctx = chartRef.current.getContext('2d');
chartInstance.current = new Chart(ctx, {
type: 'bar',
data: {
labels: data.map(item => item.label),
datasets: [{
label: 'Value',
data: data.map(item => item.value),
backgroundColor: 'rgba(75, 192, 192, 0.6)'
}]
},
options: {
responsive: true,
maintainAspectRatio: false
}
});
// Clean up the chart when the component unmounts
// or before the effect runs again
return () => {
if (chartInstance.current) {
chartInstance.current.destroy();
chartInstance.current = null;
}
};
}, [data]); // Re-run when data changes
return (
<div style={{ height: '300px' }}>
<canvas ref={chartRef} />
</div>
);
}
Optimizing useEffect
Common Dependency Array Mistakes
❌ Missing Dependencies
function Counter({ initial }) {
const [count, setCount] = useState(initial);
// Missing dependency: initial
useEffect(() => {
setCount(initial);
}, []); // Should include 'initial'
}
This effect won't run when the 'initial' prop changes.
✅ Correct Dependencies
function Counter({ initial }) {
const [count, setCount] = useState(initial);
useEffect(() => {
setCount(initial);
}, [initial]); // Includes all dependencies
}
❌ Excessive Dependencies
function ProfileCard({ user }) {
// The entire user object as a dependency
useEffect(() => {
document.title = `Profile: ${user.name}`;
}, [user]); // Re-runs on ANY change to user object
}
This will re-run if any property of user changes, not just the name.
✅ Precise Dependencies
function ProfileCard({ user }) {
// Only the specific property we need
useEffect(() => {
document.title = `Profile: ${user.name}`;
}, [user.name]); // Only re-runs when name changes
}
Avoiding Infinite Loops
❌ Infinite Loop
function InfiniteCounter() {
const [count, setCount] = useState(0);
// This creates an infinite loop!
useEffect(() => {
setCount(count + 1);
}, [count]); // Effect changes its own dependency
return <div>Count: {count}</div>;
}
✅ Controlled Update
function ControlledCounter() {
const [count, setCount] = useState(0);
// Only run once on mount
useEffect(() => {
setCount(1); // Set initial value once
}, []); // Empty dependency array
// Or use a condition
useEffect(() => {
if (count < 5) { // Limit the updates
setCount(count + 1);
}
}, [count]);
return <div>Count: {count}</div>;
}
Using Functional Updates
When updating state based on its previous value, use the functional update form to avoid unnecessary dependencies:
⚠️ Suboptimal
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
setCount(count + 1); // Depends on current count
}, 1000);
return () => clearInterval(timer);
}, [count]); // Need count as dependency
return <div>Count: {count}</div>;
}
This will create a new interval every time count changes.
✅ Optimal
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
setCount(prevCount => prevCount + 1); // Functional update
}, 1000);
return () => clearInterval(timer);
}, []); // No dependencies needed
return <div>Count: {count}</div>;
}
This creates the interval once and updates the count correctly.
Using Refs to Avoid Dependencies
Use refs to track values that you need in an effect but don't want to trigger re-runs:
function LoggerComponent({ value }) {
// Store the latest value in a ref
const valueRef = useRef(value);
// Update the ref when value changes
useEffect(() => {
valueRef.current = value;
}, [value]);
// Use an interval that doesn't need to re-run when value changes
useEffect(() => {
const intervalId = setInterval(() => {
// Access the latest value from the ref
console.log('Current value:', valueRef.current);
}, 2000);
return () => clearInterval(intervalId);
}, []); // Empty dependency array
return <div>Value: {value}</div>;
}
Advanced useEffect Patterns
Component Lifecycle with useEffect
You can replicate class component lifecycle methods using useEffect:
function LifecycleComponent() {
// componentDidMount
useEffect(() => {
console.log('Component mounted');
// componentWillUnmount
return () => {
console.log('Component will unmount');
};
}, []);
// componentDidUpdate for specific prop/state
const [count, setCount] = useState(0);
useEffect(() => {
if (count > 0) { // Skip first render
console.log('Count updated to:', count);
}
}, [count]);
// Run on every render (componentDidUpdate without checks)
useEffect(() => {
console.log('Component rendered');
});
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
}
Separating Concerns with Multiple Effects
It's better to split unrelated effects into separate useEffect calls:
function UserDashboard({ userId }) {
const [user, setUser] = useState(null);
const [posts, setPosts] = useState([]);
const [isOnline, setIsOnline] = useState(navigator.onLine);
// Effect for user data
useEffect(() => {
async function fetchUser() {
const userData = await api.getUser(userId);
setUser(userData);
}
fetchUser();
}, [userId]);
// Separate effect for posts
useEffect(() => {
if (!user) return; // Dependent on user being loaded
async function fetchPosts() {
const userPosts = await api.getUserPosts(user.id);
setPosts(userPosts);
}
fetchPosts();
}, [user]);
// Separate effect for 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);
};
}, []);
if (!user) return <div>Loading...</div>;
return (
<div>
<div className="user-info">
<h2>{user.name}</h2>
<span className={isOnline ? 'status-online' : 'status-offline'}>
{isOnline ? 'Online' : 'Offline'}
</span>
</div>
<h3>Recent Posts</h3>
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
</div>
);
}
Creating Custom Hooks with useEffect
Extract reusable effect logic into custom hooks:
// Custom hook for window size
function useWindowSize() {
const [windowSize, setWindowSize] = useState({
width: window.innerWidth,
height: window.innerHeight
});
useEffect(() => {
function handleResize() {
setWindowSize({
width: window.innerWidth,
height: window.innerHeight
});
}
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, []); // Empty dependency - only run on mount/unmount
return windowSize;
}
// Custom hook for API data
function useApiData(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let isMounted = true;
async function fetchData() {
try {
setLoading(true);
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const result = await response.json();
if (isMounted) {
setData(result);
setError(null);
}
} catch (err) {
if (isMounted) {
setError(err.message);
setData(null);
}
} finally {
if (isMounted) {
setLoading(false);
}
}
}
fetchData();
return () => {
isMounted = false;
};
}, [url]);
return { data, loading, error };
}
// Usage example
function DataDisplay() {
const windowSize = useWindowSize();
const { data, loading, error } = useApiData('https://api.example.com/data');
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return (
<div>
<p>Window size: {windowSize.width} x {windowSize.height}</p>
<pre>{JSON.stringify(data, null, 2)}</pre>
</div>
);
}
Real-World Applications
Form Auto-Save
function AutoSaveForm() {
const [formData, setFormData] = useState({
title: '',
content: ''
});
const [lastSaved, setLastSaved] = useState(null);
const [isSaving, setIsSaving] = useState(false);
const [saveStatus, setSaveStatus] = useState('idle');
// Load initial data
useEffect(() => {
// Try to load from localStorage first
const savedData = localStorage.getItem('draft');
if (savedData) {
try {
setFormData(JSON.parse(savedData));
setLastSaved(new Date());
setSaveStatus('loaded');
} catch (e) {
console.error('Failed to parse saved data:', e);
}
}
}, []); // Empty dependency array - run once on mount
// Auto-save effect
useEffect(() => {
// Skip the initial render or empty form
if (saveStatus === 'idle' || (!formData.title && !formData.content)) {
return;
}
// Set up a timer for auto-save
const timerId = setTimeout(() => {
setIsSaving(true);
setSaveStatus('saving');
// Simulate saving to server
setTimeout(() => {
// Save to localStorage for demo
localStorage.setItem('draft', JSON.stringify(formData));
setLastSaved(new Date());
setIsSaving(false);
setSaveStatus('saved');
}, 500);
}, 1000); // Auto-save 1 second after typing stops
// Clear timeout if formData changes again before save triggers
return () => {
clearTimeout(timerId);
};
}, [formData, saveStatus]);
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
if (saveStatus === 'saved') {
setSaveStatus('editing');
}
};
const handleSubmit = (e) => {
e.preventDefault();
// Here you would normally send the data to a server
alert('Form submitted: ' + JSON.stringify(formData));
// Clear the draft
localStorage.removeItem('draft');
setFormData({ title: '', content: '' });
setLastSaved(null);
setSaveStatus('idle');
};
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="title">Title:</label>
<input
id="title"
name="title"
value={formData.title}
onChange={handleChange}
required
/>
</div>
<div>
<label htmlFor="content">Content:</label>
<textarea
id="content"
name="content"
value={formData.content}
onChange={handleChange}
rows="6"
required
/>
</div>
<div className="form-status">
{isSaving && <span>Saving...</span>}
{!isSaving && lastSaved && (
<span>Last saved: {lastSaved.toLocaleTimeString()}</span>
)}
</div>
<button type="submit">Submit</button>
</form>
);
}
Real-Time Updates with WebSocket
function ChatRoom({ roomId }) {
const [messages, setMessages] = useState([]);
const [connected, setConnected] = useState(false);
const [inputMessage, setInputMessage] = useState('');
const socketRef = useRef(null);
// Effect for connecting to WebSocket
useEffect(() => {
// Create a new WebSocket connection
const socket = new WebSocket(`wss://chat.example.com/rooms/${roomId}`);
socketRef.current = socket;
// Set up event handlers
socket.addEventListener('open', () => {
console.log('Connected to chat room:', roomId);
setConnected(true);
});
socket.addEventListener('message', (event) => {
const message = JSON.parse(event.data);
setMessages(prev => [...prev, message]);
});
socket.addEventListener('close', () => {
console.log('Disconnected from chat room');
setConnected(false);
});
socket.addEventListener('error', (error) => {
console.error('WebSocket error:', error);
setConnected(false);
});
// Clean up when component unmounts or roomId changes
return () => {
console.log('Closing WebSocket connection');
socket.close();
};
}, [roomId]); // Re-connect if room changes
// Function to send a message
const sendMessage = (e) => {
e.preventDefault();
if (!inputMessage.trim() || !connected) return;
const message = {
type: 'chat',
content: inputMessage,
sender: 'Me', // In a real app, this would be the user's identity
timestamp: new Date().toISOString()
};
socketRef.current.send(JSON.stringify(message));
setInputMessage('');
};
return (
<div className="chat-room">
<div className="chat-header">
<h2>Chat Room: {roomId}</h2>
<div className={`connection-status ${connected ? 'connected' : 'disconnected'}`}>
{connected ? 'Connected' : 'Disconnected'}
</div>
</div>
<div className="chat-messages">
{messages.length === 0 ? (
<p className="no-messages">No messages yet.</p>
) : (
messages.map((msg, index) => (
<div
key={index}
className={`message ${msg.sender === 'Me' ? 'sent' : 'received'}`}
>
<div className="message-sender">{msg.sender}</div>
<div className="message-content">{msg.content}</div>
<div className="message-time">
{new Date(msg.timestamp).toLocaleTimeString()}
</div>
</div>
))
)}
</div>
<form className="chat-input" 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 Component
Create a component that fetches and displays data from a public API, with the following features:
- Loading state
- Error handling
- Ability to refresh data
- Prevention of race conditions
Use the JSONPlaceholder API: https://jsonplaceholder.typicode.com/posts
Exercise 2: Countdown Timer
Build a countdown timer that:
- Allows setting an initial time in minutes and seconds
- Counts down to zero
- Can be paused and resumed
- Plays a sound or shows an alert when time is up
- Cleans up intervals properly when unmounted
Exercise 3: Form with Auto-Complete
Create a form with an auto-complete feature:
- As the user types in a text field, fetch matching suggestions
- Implement debouncing to prevent too many API calls
- Display suggestions in a dropdown
- Allow selecting a suggestion by clicking or keyboard navigation
- Handle loading and error states
You can use the GitHub Users API for suggestions: https://api.github.com/search/users?q={query}
Summary
In this lecture, we've covered:
- The purpose and importance of the
useEffectHook for handling side effects - How to use the dependency array to control when effects run
- Proper cleanup of effects to prevent memory leaks
- Common use cases like data fetching, timers, and DOM manipulation
- Techniques for avoiding common pitfalls like infinite loops
- Advanced patterns like custom hooks and handling race conditions
- Real-world applications that demonstrate integrating multiple effects
The useEffect Hook is a powerful tool that lets you synchronize your React components with external systems.
By mastering useEffect, you'll be able to create components that interact smoothly with APIs, timers, DOM elements,
and other external resources while maintaining React's declarative programming model.