What is an Effect in React?
In the React ecosystem, we can think of our components as having three primary types of logic:
Renders
The pure transformation of state, props, and context into JSX
Events
Direct responses to user interactions like clicks or form submissions
Effects
Side effects that synchronize your React component with external systems
To understand effects, we need to first understand the principle of purity in React. Think of a React component as a recipe: given the same ingredients (props, state, and context), it should always produce the same dish (JSX). This predictability is what makes React powerful and debuggable.
The Pure Function Analogy
Imagine a vending machine that always gives you the same snack when you press button A3. If sometimes it gave you chips and other times it gave you chocolate for the same button press, it would be frustrating and unpredictable. React components work the same way – given the same inputs, they should produce the same outputs, with no surprises.
However, in real-world applications, we often need to interact with systems outside of React's control – like browser APIs, third-party libraries, or backend services. This is where useEffect comes in.
The useEffect Hook: Your Component's Connection to the Outside World
The useEffect hook is React's way of letting your component interact with systems outside of React's control after rendering has taken place. Think of it as a way to say, "Hey React, once you've painted this component on the screen, I need you to do something extra."
import { useEffect } from 'react';
function WeatherWidget() {
useEffect(() => {
// This code runs after the component renders
console.log('Weather widget has rendered!');
});
return <div>Current Weather: Sunny</div>;
}
This simple example logs a message to the console after the component renders. But the real power of useEffect is in how it lets you synchronize your React components with external systems.
When does useEffect run?
The useEffect callback runs after the browser has painted the component on screen. This is a crucial detail to understand – the effect happens after rendering, not during it. This ensures that your render logic stays pure while still allowing your component to have side effects.
Real-World Uses of useEffect
Let's explore some common scenarios where useEffect is essential:
Data Fetching
One of the most common uses of useEffect is fetching data from an API after a component mounts.
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// This effect fetches user data when the component mounts
async function fetchUserData() {
setLoading(true);
try {
const response = await fetch(`https://api.example.com/users/${userId}`);
const userData = await response.json();
setUser(userData);
} catch (error) {
console.error('Failed to fetch user data:', error);
} finally {
setLoading(false);
}
}
fetchUserData();
});
if (loading) return <div>Loading...</div>;
if (!user) return <div>User not found</div>;
return (
<div>
<h2>{user.name}'s Profile</h2>
<p>Email: {user.email}</p>
</div>
);
}
DOM Manipulations
Sometimes you need to directly interact with the DOM after React has rendered it:
function AutoFocusInput() {
// Create a ref to store the input element
const inputRef = useRef(null);
useEffect(() => {
// Focus the input element after the component renders
inputRef.current.focus();
});
return <input ref={inputRef} placeholder="I'll be focused automatically" />;
}
Subscriptions
Managing subscriptions to external data sources:
function ChatRoom({ roomId }) {
const [messages, setMessages] = useState([]);
useEffect(() => {
// Subscribe to chat messages when the component mounts
const chatService = new ChatService(roomId);
chatService.subscribe(newMessage => {
setMessages(prevMessages => [...prevMessages, newMessage]);
});
// We'll discuss this return function in the cleanup section
return () => {
chatService.unsubscribe();
};
});
return (
<div className="chat-room">
{messages.map(msg => (
<div key={msg.id} className="message">
{msg.text}
</div>
))}
</div>
);
}
Understanding the Rendering and Effect Order
Let's see the exact order in which rendering and effects happen with a practical example:
function Parent() {
console.log('Rendering Parent component');
useEffect(() => {
console.log('Parent effect executed');
});
return (
<div>
<h2>Parent Component</h2>
<Child />
</div>
);
}
function Child() {
console.log('Rendering Child component');
useEffect(() => {
console.log('Child effect executed');
});
return <div>Child Component</div>;
}
If we render the Parent component, the console will show this sequence:
- Rendering Parent component
- Rendering Child component
- Parent effect executed
- Child effect executed
The Theater Production Analogy
Think of React rendering like a theater production:
- First, all the actors (components) get on stage and take their positions (rendering phase)
- Once everyone is in place and the curtain is up (the DOM is updated), the special effects and lighting cues (useEffect callbacks) start running
Just like in theater, you want to make sure the stage is set before running effects that depend on the scene being ready!
React StrictMode and useEffect
If you're using React in development with <React.StrictMode>, you'll notice that your effects run twice. This is intentional and helps catch bugs related to missing cleanup functions.
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
With this setup, if you have a useEffect like this:
useEffect(() => {
console.log('Effect running!');
});
You'll see "Effect running!" logged twice in development mode. This is not a bug but a feature designed to help you catch issues early. In production, the effect will only run once as expected.
Follow-Along Exercise: Building a Timer Component
Let's create a practical example to understand useEffect better. We'll build a simple timer component that counts seconds since it was mounted.
Step 1: Project Setup
First, let's create a new React application or use an existing one. If you're starting from scratch, you can use Create React App or Vite:
# Using Vite (recommended)
npm create vite@latest my-effect-demo -- --template react
cd my-effect-demo
npm install
# Or using Create React App
npx create-react-app my-effect-demo
cd my-effect-demo
Step 2: Create a Timer Component
Create a new file in your src folder called Timer.jsx:
// src/Timer.jsx
import React, { useState, useEffect } from 'react';
function Timer() {
const [seconds, setSeconds] = useState(0);
useEffect(() => {
// This code runs after the component renders
console.log('Timer component mounted!');
// Create an interval that increments the seconds every 1000ms
const intervalId = setInterval(() => {
setSeconds(prevSeconds => prevSeconds + 1);
}, 1000);
// This cleanup function runs when the component unmounts
return () => {
console.log('Timer component unmounted!');
clearInterval(intervalId); // Clean up the interval
};
}, []); // Empty dependency array means this effect runs once after initial render
return (
<div className="timer">
<h2>Timer Demo</h2>
<p>Seconds elapsed: {seconds}</p>
</div>
);
}
export default Timer;
Step 3: Use the Timer in Your App
Now, modify your src/App.jsx file to include the Timer component:
// src/App.jsx
import React, { useState } from 'react';
import Timer from './Timer';
import './App.css';
function App() {
const [showTimer, setShowTimer] = useState(true);
return (
<div className="App">
<h1>useEffect Demo</h1>
<button onClick={() => setShowTimer(!showTimer)}>
{showTimer ? 'Hide Timer' : 'Show Timer'}
</button>
{showTimer && <Timer />}
</div>
);
}
export default App;
Step 4: Run the Application
Start your development server:
npm run dev # For Vite
# or
npm start # For Create React App
Step 5: Experiment
Open your browser to see the timer running. Try clicking the button to hide and show the timer. Watch the console to see the mount and unmount logs. This demonstrates the full lifecycle of a useEffect with both setup and cleanup.
The Dependency Array: Controlling When Effects Run
You might have noticed the empty array ([]) in our Timer component's useEffect. This is called the dependency array, and it's a crucial part of controlling when your effects run.
No Dependency Array
useEffect(() => {
console.log('This runs after every render');
});
The effect runs after every render of the component.
Empty Dependency Array
useEffect(() => {
console.log('This runs once after the initial render');
}, []);
The effect runs only once after the component mounts (initial render).
With Dependencies
useEffect(() => {
console.log(`User ID changed to: ${userId}`);
}, [userId]);
The effect runs after the initial render AND whenever any of the dependencies (userId in this case) change.
The Subscription Analogy
Think of the dependency array like a subscription service:
- No dependency array: "Notify me of ALL changes, no matter what"
- Empty array
[]: "Just notify me once when I first sign up, then never again" - With dependencies
[userId]: "Only notify me when the user ID changes"
The Cleanup Function: Preventing Memory Leaks
Another crucial aspect of useEffect is the cleanup function. This is a function that you return from your effect, which React will call before the component unmounts or before re-running the effect due to changing dependencies.
useEffect(() => {
// 1. Setup: Runs after rendering
const subscription = subscribeToData();
// 2. Cleanup: Runs before unmount or before re-running the effect
return () => {
subscription.unsubscribe();
};
}, [dependencies]);
Cleanup functions are essential for:
- Cancelling network requests
- Clearing timers (setTimeout, setInterval)
- Unsubscribing from external data sources or event listeners
- Releasing resources like WebSocket connections or third-party libraries
Warning: Memory Leaks
Forgetting to clean up effects can lead to memory leaks, especially for subscriptions and timers. Always remember to clean up any resources your effect creates!
Practical Example: Building a Window Resize Tracker
Let's build a component that tracks and displays the window's dimensions, demonstrating a proper setup and cleanup pattern.
// src/WindowSize.jsx
import React, { useState, useEffect } from 'react';
function WindowSize() {
const [windowSize, setWindowSize] = useState({
width: window.innerWidth,
height: window.innerHeight
});
useEffect(() => {
// Define the event handler function
const handleResize = () => {
setWindowSize({
width: window.innerWidth,
height: window.innerHeight
});
};
// Setup: Add the event listener
window.addEventListener('resize', handleResize);
console.log('Added resize event listener');
// Cleanup: Remove the event listener when component unmounts
return () => {
window.removeEventListener('resize', handleResize);
console.log('Removed resize event listener');
};
}, []); // Empty dependency array means run once on mount
return (
<div className="window-size">
<h2>Window Size Tracker</h2>
<p>
Current dimensions: {windowSize.width} x {windowSize.height}
</p>
</div>
);
}
export default WindowSize;
Add this component to your App.jsx to try it out:
// In App.jsx, add:
import WindowSize from './WindowSize';
// Then in your JSX:
{showWindowSize && <WindowSize />}
This example demonstrates a complete useEffect lifecycle with:
- Initial setup (adding the event listener)
- State updates in response to external events
- Proper cleanup (removing the event listener)
Common useEffect Patterns and Best Practices
Pattern: Data Fetching with Proper Cleanup
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Create an abort controller for cleanup
const abortController = new AbortController();
const signal = abortController.signal;
async function fetchUser() {
setLoading(true);
try {
const response = await fetch(`https://api.example.com/users/${userId}`, {
signal // Pass the abort signal to fetch
});
if (signal.aborted) return; // Stop if we've been aborted
const data = await response.json();
setUser(data);
} catch (error) {
if (error.name !== 'AbortError') {
console.error('Fetch error:', error);
}
} finally {
if (!signal.aborted) {
setLoading(false);
}
}
}
fetchUser();
// Cleanup function to abort fetch if component unmounts
return () => {
abortController.abort();
};
}, [userId]); // Re-run when userId changes
// Render logic here
}
Pattern: Debounced Input
function SearchInput({ onSearch }) {
const [searchTerm, setSearchTerm] = useState('');
// This effect handles debouncing
useEffect(() => {
// Don't search for empty strings
if (!searchTerm.trim()) return;
// Set a timer to perform the search after 500ms of inactivity
const timer = setTimeout(() => {
console.log(`Searching for: ${searchTerm}`);
onSearch(searchTerm);
}, 500);
// Clear the timer if searchTerm changes before the timeout
// or if the component unmounts
return () => clearTimeout(timer);
}, [searchTerm, onSearch]); // Re-run when searchTerm or onSearch changes
return (
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search..."
/>
);
}
Best Practices
- Keep effects focused: Each effect should do one thing well. Split complex logic into multiple effects.
- Avoid unnecessary dependencies: Only include variables that the effect actually uses in its internal logic.
- Don't forget cleanup: Always clean up resources created in effects.
- Be careful with objects and functions as dependencies: They can cause effects to run more often than intended due to referential equality.
- Handle race conditions: For async operations like data fetching, make sure to handle cases where responses come back in a different order than requested.
Practice Exercise: Building a Local Storage Sync Component
Let's put everything together with a practical exercise. We'll build a component that syncs its state with localStorage:
Exercise Requirements
Create a component that:
- Loads its initial state from localStorage (if available)
- Syncs any state changes back to localStorage
- Includes a form with at least one input field
- Properly handles cleanup and edge cases
Starting Template
Create a new file called src/LocalStorageForm.jsx:
// src/LocalStorageForm.jsx
import React, { useState, useEffect } from 'react';
function LocalStorageForm() {
// Your code goes here!
// 1. Setup state for form fields
// 2. Load initial values from localStorage
// 3. Add effect to sync state changes to localStorage
// 4. Create a form UI
return (
<div className="storage-form">
<h2>Persistent Form</h2>
{/* Your form JSX here */}
</div>
);
}
export default LocalStorageForm;
Solution
Here's a complete solution you can study and adapt:
// src/LocalStorageForm.jsx - Solution
import React, { useState, useEffect } from 'react';
function LocalStorageForm() {
// Step 1: Setup state with default values
const [formData, setFormData] = useState({
name: '',
email: '',
theme: 'light'
});
// Step 2: Load initial values from localStorage on mount
useEffect(() => {
const savedData = localStorage.getItem('formData');
if (savedData) {
try {
// Parse stored JSON
const parsedData = JSON.parse(savedData);
setFormData(parsedData);
console.log('Loaded data from localStorage:', parsedData);
} catch (error) {
console.error('Failed to parse localStorage data:', error);
// If parsing fails, clear localStorage to prevent future errors
localStorage.removeItem('formData');
}
}
}, []); // Empty dependency array = run once on mount
// Step 3: Sync state changes to localStorage
useEffect(() => {
// Only save if we have some actual data (not the initial empty state)
if (formData.name || formData.email) {
localStorage.setItem('formData', JSON.stringify(formData));
console.log('Saved to localStorage:', formData);
}
}, [formData]); // Run whenever formData changes
// Handle input changes
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prevData => ({
...prevData,
[name]: value
}));
};
// Handle form submission
const handleSubmit = (e) => {
e.preventDefault();
alert(`Form submitted with: ${JSON.stringify(formData, null, 2)}`);
};
// Handle form reset (clear localStorage too)
const handleReset = () => {
localStorage.removeItem('formData');
setFormData({
name: '',
email: '',
theme: 'light'
});
};
return (
<div className="storage-form">
<h2>Persistent Form</h2>
<p>This form remembers your input even if you reload the page!</p>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="name">Name:</label>
<input
type="text"
id="name"
name="name"
value={formData.name}
onChange={handleChange}
/>
</div>
<div className="form-group">
<label htmlFor="email">Email:</label>
<input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleChange}
/>
</div>
<div className="form-group">
<label htmlFor="theme">Theme:</label>
<select
id="theme"
name="theme"
value={formData.theme}
onChange={handleChange}
>
<option value="light">Light</option>
<option value="dark">Dark</option>
<option value="system">System</option>
</select>
</div>
<div className="form-actions">
<button type="submit">Submit</button>
<button type="button" onClick={handleReset}>
Reset Form & Clear Storage
</button>
</div>
</form>
</div>
);
}
export default LocalStorageForm;
Common Pitfalls and Debugging Tips
Infinite Loops
One of the most common mistakes is creating an infinite loop by updating state inside an effect without proper dependencies:
// ❌ BAD: Creates an infinite loop
useEffect(() => {
setCount(count + 1); // Updates state, causing re-render
}); // No dependency array means it runs after every render
// ✅ GOOD: Only runs when dependencies change
useEffect(() => {
// Do something with count, but don't update it without a condition
}, [count]);
Stale Closures
Functions inside effects capture variables from the render in which they were created:
// ❌ BAD: Timer will always use the initial value of count
useEffect(() => {
const timer = setInterval(() => {
console.log(`Count is: ${count}`);
}, 1000);
return () => clearInterval(timer);
}, []); // Empty dependency array = timer function captures initial count
// ✅ GOOD: Timer will use latest count value
useEffect(() => {
const timer = setInterval(() => {
console.log(`Count is: ${count}`);
}, 1000);
return () => clearInterval(timer);