useEffect — when to use it and when to avoid it
The definition of side effects, the dependency array, the role of cleanup, and the cases where useEffect should not be used — all in one chapter.
Chapter 9 wrapped up Part 1. The components we built so far started and ended every story inside themselves. Receiving user input, holding it in state, painting it on screen — the whole cycle was closed inside the component. Part 2 starts with this chapter. We cover useEffect, the tool the component reaches for when it has to interact with the outside world.
useEffect is as easy to misuse as it is powerful. This chapter covers not only how to use it, but also “the cases where you should not use useEffect”. And in Chapter 25 (data fetching and caching) we look at how the Next.js RSC environment replaces the most common use of useEffect + fetch.
What a side effect is #
A side effect is any work besides the component’s core role of “turning props / state into JSX”.
- Fetching data from the server (
fetch) - Setting timers (
setTimeout,setInterval) - Using browser APIs (
localStorage, changingdocument.title, etc.) - Initializing external libraries
- Registering event listeners (
window.addEventListener)
All of these are separate from producing the render output (JSX). They are not unrelated to the component either, though. Which data to fetch, when to turn a timer on or off — those decisions depend on the component’s props / state.
React’s recommendation is to keep this kind of work out of the component function body and put it inside useEffect instead.
Why you should not write it in the function body #
Look at the following code.
function Profile({ userId }) {
const [user, setUser] = useState(null);
// 🚫 fetch directly in the function body
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => setUser(data));
return user ? <p>{user.name}</p> : <p>Loading...</p>;
}There are two problems.
- A fetch happens on every render. When
setUserchanges state, the component renders again, another fetch fires,setUseris called again — an infinite loop. - It violates React’s principle that rendering should be fast and pure.
useEffect separates this kind of work so it runs after rendering finishes and only when necessary.
Basic useEffect usage #
import { useState, useEffect } from 'react';
function Profile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => setUser(data));
}, [userId]);
return user ? <p>{user.name}</p> : <p>Loading...</p>;
}
export default Profile;The core shape:
useEffect(() => {
// code to run
}, [dependency, array]);- First argument — the effect function (the code to run)
- Second argument — the dependency array (the effect re-runs only when these values change)
React runs the effect function after the component is painted on screen. On the next render, if the values in the dependency array are the same as before, React skips the effect; if they differ, it runs again.
The three shapes of the dependency array #
1. Empty array [] — once at mount
#
useEffect(() => {
console.log('Component appeared on screen for the first time');
}, []);An empty array means there is nothing to depend on, so the effect runs only once. Commonly used for initial data loading and one-time initialization work.
2. Explicit dependencies [a, b] — when those values change
#
useEffect(() => {
fetch(`/api/users/${userId}`).then(/* ... */);
}, [userId]);When userId changes, the fetch runs again. If the same value comes in, the effect does not re-run — that’s efficient. The basic rule is every prop / state you use inside the effect must appear in the dependency array. Leaving them out tends to introduce bugs where the effect closes over stale values.
3. Omitting the array entirely — every render #
useEffect(() => {
console.log('rendered');
});With the dependency array left off entirely, the effect runs on every render. There is almost no real use case for this, and it is usually a recipe for accidental infinite loops, so it is better to consciously avoid it.
react-hooks/exhaustive-deps rule, which catches missing dependencies automatically. Vite’s default ESLint setup includes it, so you see warnings as you write code. Don’t dismiss the warnings — following them is almost always correct.The cleanup function #
Resources registered by an effect (timers, event listeners, subscriptions) often need to be cleaned up. Just before the component disappears from the screen, or just before the effect re-runs because a dependency changed, you have to clean up what the previous effect set up.
If the effect function returns a function, React calls that returned function at cleanup time.
import { useState, useEffect } from 'react';
function Clock() {
const [time, setTime] = useState(new Date());
useEffect(() => {
const id = setInterval(() => {
setTime(new Date());
}, 1000);
return () => clearInterval(id); // cleanup
}, []);
return <p>{time.toLocaleTimeString()}</p>;
}
export default Clock;When this component goes away, React calls the returned function and clearInterval tears down the timer. Without cleanup the timer keeps living after the component is gone, leaking memory and triggering unexplained bugs.
Cleanup also runs when dependencies change #
useEffect(() => {
let cancelled = false;
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => {
if (!cancelled) setUser(data);
});
return () => {
cancelled = true;
};
}, [userId]);- Fetch starts while
userIdis1 - Before the response arrives, the user navigates and
userIdchanges to2 - React runs the previous effect’s cleanup first (
cancelled = true) - The new effect runs and fetches for
2 - The late response for
1arrives but is ignored becausecancelled === true
Handling race conditions this way is another common use of cleanup.
Common patterns #
Fetching data #
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
setError(null);
fetch(`/api/users/${userId}`)
.then(res => {
if (!res.ok) throw new Error('Request failed');
return res.json();
})
.then(data => setUser(data))
.catch(err => setError(err.message))
.finally(() => setLoading(false));
}, [userId]);
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error}</p>;
if (!user) return null;
return <p>{user.name}</p>;
}The canonical pattern is to express the full state of an async request with three pieces of state: loading / error / data. Turning loading off in .finally() handles both success and failure consistently.
In practice, it is more common to use a data-fetching library like TanStack Query than to wire up useEffect + fetch by hand. The library handles caching, retries, background sync, and so on. That said, those libraries are themselves built on top of useEffect, so understanding how the primitive works helps you understand them too.
Part 4 of this book (Modern Next.js) goes one step further. In a Server Components environment, data fetching does not happen inside a client useEffect at all — it happens directly in the body of the server component function. Chapter 25 (data fetching and caching) covers that model.
Registering event listeners #
function WindowSize() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
function handleResize() {
setWidth(window.innerWidth);
}
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return <p>Window width: {width}px</p>;
}addEventListener ↔ removeEventListener must be paired, so cleanup is required.
Syncing external state like document.title #
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
document.title = `Count: ${count}`;
}, [count]);
// ...
}document.title lives outside React’s domain, so syncing it with our state requires an effect.
localStorage sync #
function Settings() {
const [theme, setTheme] = useState(() => {
return localStorage.getItem('theme') ?? 'light';
});
useEffect(() => {
localStorage.setItem('theme', theme);
}, [theme]);
// ...
}Passing a function to useState’s initial value runs that function only on the first mount. That prevents localStorage.getItem from being called on every render. After that, the effect saves the new value whenever theme changes.
When you should not use useEffect #
This is a point the official React docs underline. Don’t use useEffect for work that can be done by plain computation. Half of useEffect misuse starts here.
1. A value computed from other state #
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const fullName = `${firstName} ${lastName}`;fullName is always derivable from firstName / lastName. Holding it as its own state pushes the burden of keeping the two in sync onto us. Leaving it as a plain variable always gives the correct value automatically. This is the same idea as “Single Source of Truth” in Chapter 11 (lifting state up).
2. Work that belongs in an event handler #
const [submitted, setSubmitted] = useState(false);
useEffect(() => {
if (submitted) {
sendAnalytics('submit');
}
}, [submitted]);
function handleSubmit() {
setSubmitted(true);
}function handleSubmit() {
sendAnalytics('submit');
}“When a click happens, do X” belongs directly in the click handler. There is no reason to detour through state and an effect.
3. One-time initialization #
function App() {
useEffect(() => {
initializeAnalytics();
}, []);
// ...
}initializeAnalytics();
function App() {
// ...
}Initialization code that needs to run only once across the entire app has no reason to live inside a component. Running it once at the module’s top level is enough.
The test #
To decide whether useEffect is really necessary, ask one question.
Does this work involve the outside world (server, timer, DOM API, browser API)?
If not, you almost certainly don’t need useEffect.
Common mistakes #
Missing dependencies #
useEffect(() => {
fetch(`/api/users/${userId}`).then(/* ... */);
}, []);With an empty array, the effect fetches only with the userId from the first render and never refetches when userId changes. ESLint catches this — follow the warning.
Infinite setState inside an effect #
useEffect(() => {
setCount(count + 1); // 🚫 mutating count inside an effect with [count]
}, [count]);Changing state triggers a render, the dependency has changed so the effect runs again, which changes state again — an endless loop. An effect’s job is to change the outside world, not to mutate its own state perpetually.
Try it yourself #
Let’s build a simple clock + page title sync component.
src/ClockTitle.jsx:
import { useState, useEffect } from 'react';
function ClockTitle() {
const [time, setTime] = useState(new Date());
useEffect(() => {
const id = setInterval(() => setTime(new Date()), 1000);
return () => clearInterval(id);
}, []);
useEffect(() => {
document.title = `Current time: ${time.toLocaleTimeString()}`;
}, [time]);
return (
<div style={{ padding: '16px', border: '1px solid #ccc', borderRadius: '8px' }}>
<h2>{time.toLocaleTimeString()}</h2>
<p>Check that the browser tab title changes along with the clock.</p>
</div>
);
}
export default ClockTitle;Once saved, the clock updates every second and the browser tab title changes along with it. You can see two useEffects doing different things side by side. The first updates the time once a second; the second syncs the tab title each time the time changes.
Exercises #
- Build a
WindowSizecomponent that displayswindow.innerWidthandwindow.innerHeightand updates automatically as the window is resized. Register aresizeevent listener and release it in cleanup. You also need to read the initial values correctly at mount time. - Practice spotting useEffect misuse. Given the following two variables, decide which side needs useEffect. (a) Computing
fullNamefromfirstNameandlastName. (b) Fetching info from/api/users/:idwheneveruserIdchanges. (a) is done without useEffect, as a plain variable; (b) is done with useEffect + cleanup. - A self-cleaning timer. Use
useStateforseconds, and increment it by 1 every second from mount. When the component disappears (e.g., when the parent conditionally removes it), confirm in the console thatsetIntervalis cleaned up so there is no memory leak. While you are at it, observe React strict mode causing the effect to run twice in dev.
In one line:
useEffect(fn, deps)runsfnwheneverdepschange.[]means once at mount,[a]means wheneverachanges, and omitting it means every render. Returning a function gives you cleanup. Every prop / state used inside the effect must be in the dependency array. Plain computation, event handling, and one-time initialization are not the place for useEffect.
Next chapter #
Every component we have built so far had its own state. But what about when two sibling components need to share the same state? In the next Chapter 11 Lifting state up, we cover lifting state up, the core pattern for exactly that case.