The useEffect Hook
Learn how useEffect lets your React components reach outside themselves — fetching data, setting up timers, and cleaning up after themselves when they're done.
React components have one job: take some data and return some UI. But real apps need to do more than that. They fetch data from a server. They set up a timer that ticks every second. They subscribe to a WebSocket that streams live updates. They update the browser tab's title.
None of these things fit neatly inside a return statement — they reach outside the component into the world beyond React. We call these side effects, and useEffect is the hook React gives you to manage them safely.
#What Is a Side Effect?
A Chef Who Also Texts the Table
Imagine a chef whose job is to cook a dish (return UI). But sometimes the chef also needs to do something outside the kitchen — text the table that the food is ready, order more ingredients, or turn off the stove after a timer.
Those extra tasks are side effects: they happen as a consequence of cooking, but they aren't the dish itself. useEffect is React's way of saying: "after you've cooked and served the dish, here's where you handle those extra tasks."
Common side effects in React include: - Fetching data from an API after the component appears on screen - Setting up a timer with setInterval or setTimeout - Subscribing to an event or a WebSocket connection - Updating the document title shown in the browser tab - Reading from localStorage
All of these are jobs for useEffect.
#The Basic Shape of useEffect
useEffect takes two arguments: a callback function where your side effect lives, and an optional dependency array that tells React when to re-run it.
import { useState, useEffect } from 'react';
function DocumentTitle() {
const [count, setCount] = useState(0);
useEffect(() => {
document.title = `You clicked ${count} times`;
});
return (
<button onClick={() => setCount(count + 1)}>
Click me ({count})
</button>
);
}React runs the callback after the component renders and the DOM has updated. That timing is intentional — your effect always sees the most up-to-date values on screen.
#The Dependency Array: Controlling When Effects Run
Running an effect after every render is often too much. If you're fetching data, you don't want to hit the network on every keystroke. The dependency array (the second argument) is how you tell React when to re-run the effect.
There are three cases:
// 1. No array — runs after EVERY render
useEffect(() => {
console.log('Runs every time');
});
// 2. Empty array — runs ONCE after the first render
useEffect(() => {
console.log('Runs once on mount');
}, []);
// 3. Array with values — runs when those values change
useEffect(() => {
console.log('userId changed:', userId);
}, [userId]);The empty array [] is your go-to for data fetching — you want to load data once when the component first appears, not on every update. The array with values is perfect for reacting to specific changes, like re-fetching a user's profile whenever their userId changes.
#Fetching Data with useEffect
Here's a practical, complete example: fetching a user from an API when the component mounts.
import { useState, useEffect } from 'react';
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
fetch(`https://api.example.com/users/${userId}`)
.then(res => res.json())
.then(data => {
setUser(data);
setLoading(false);
});
}, [userId]);
if (loading) return <p>Loading...</p>;
return <h2>Hello, {user.name}!</h2>;
}#The Cleanup Function
Some effects need to be undone when the component leaves the screen (unmounts) or before the effect runs again. A timer needs to be cleared. A subscription needs to be cancelled. An event listener needs to be removed.
You handle this by returning a function from your effect — React calls it at the right moment automatically.
import { useState, useEffect } from 'react';
function Clock() {
const [seconds, setSeconds] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setSeconds(prev => prev + 1);
}, 1000);
// Cleanup: stop the timer when component unmounts
return () => clearInterval(interval);
}, []); // Empty array: set up once, clean up once
return <p>Elapsed: {seconds}s</p>;
}When Does Cleanup Run?
React calls your cleanup function in two situations: 1. Before the effect runs again — so the old setup is torn down before the new one starts. 2. When the component unmounts — so you don't leave timers or listeners running in the background.
Think of it as always tidying up before leaving the kitchen.
#Common Pitfalls
Infinite Loops: The Most Common useEffect Mistake
If you set state inside an effect without a dependency array (or with a dependency that changes every render), you'll trigger an infinite loop:
``jsx // DANGER: infinite loop! useEffect(() => { setCount(count + 1); // triggers re-render }); // runs again after re-render... forever ``
The fix is almost always adding a proper dependency array. If the effect should run once, use []. If it should run when specific values change, list exactly those values.
Missing Dependencies
If your effect uses a variable but you don't include it in the dependency array, your effect will run with a stale (out-of-date) copy of that value. This is called a stale closure and causes subtle bugs.
If you have ESLint's eslint-plugin-react-hooks installed (which Create React App and Vite include by default), it will warn you when you forget a dependency. Listen to those warnings — they're almost always right.
You want to fetch data from an API exactly once when the component first appears on screen. Which useEffect call is correct?
Key takeaways
- `useEffect` is where you handle side effects — anything that reaches outside React, like fetching data, timers, or subscriptions.
- The dependency array controls when the effect runs: no array means every render, `[]` means once on mount, `[value]` means when that value changes.
- Return a cleanup function from your effect to cancel timers, remove listeners, or undo anything that would otherwise leak.
- Missing a dependency in the array causes stale data bugs; including a frequently-changing value without care can cause infinite loops.
- For data fetching, the pattern is: empty array to fetch once, state variables for loading/data/error, and cleanup to handle fast prop changes.
This code has a bug — what's wrong?
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
setCount(count + 1);
});
return <p>{count}</p>;
}This effect should re-run only when `userId` changes. Fill in the second argument to useEffect so it re-fetches whenever userId is different.
useEffect(() => { fetch(`https://api.example.com/users/${userId}`) .then(res => res.json()) .then(data => setUser(data)); }, );
Arrange these lines into a correct useEffect that sets up a resize listener and cleans it up on unmount.
}, []);
window.addEventListener('resize', handleResize);const handleResize = () => setWidth(window.innerWidth);
useEffect(() => { return () => window.removeEventListener('resize', handleResize);What does the console log print across the first render and one later re-render caused by a state update elsewhere in the component?
function Greeting() {
const [name] = useState('Ada');
useEffect(() => {
console.log('effect ran');
}, []);
return <h2>Hi {name}</h2>;
}Build a WindowWidth component that displays the current width of the browser window in pixels. It should update in real time as the user resizes the window. Use useEffect to add a resize event listener, and clean it up when the component unmounts.
Try it live — edit the code and hit Run to see it rendered: