useEffect
Running code outside the render — subscriptions, timers, and data fetching — and cleaning up after it.
What effects are for
Rendering must be pure. Anything that reaches outside React — a timer, a network request, a browser API, an event listener on window — goes in an effect, which runs after the render has been committed to the screen.
import { useEffect } from "react";
function PageTitle({ title }) {
useEffect(() => {
document.title = title;
}, [title]);
return <h1>{title}</h1>;
}The dependency array
The second argument decides when the effect runs again. This is the part that causes most trouble, so it is worth learning the three shapes exactly.
useEffect(() => {
// after every render
});
useEffect(() => {
// once, after the first render
}, []);
useEffect(() => {
// after the first render, and again whenever userId changes
}, [userId]);Every reactive value the effect reads — props, state, and anything derived from them — belongs in the array. Leaving one out gives you an effect that quietly works with stale values.
Cleanup
Returning a function from an effect tells React how to undo it. The cleanup runs before the effect runs again, and when the component is removed. Anything you subscribe to, open, or schedule needs one.
function Clock() {
const [now, setNow] = useState(() => new Date());
useEffect(() => {
const id = setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(id);
}, []);
return <time>{now.toLocaleTimeString()}</time>;
}useEffect(() => {
function handleResize() {
setWidth(window.innerWidth);
}
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);Without cleanup, a component that mounts and unmounts repeatedly leaves a trail of live intervals and listeners behind it.
Fetching data
A request started for one set of props may finish after the props have changed. Guard against it, or two responses arriving out of order will show the wrong one.
function User({ userId }) {
const [user, setUser] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
async function load() {
try {
const res = await fetch(`/api/users/${userId}`);
if (!res.ok) throw new Error("Request failed: " + res.status);
const data = await res.json();
if (!cancelled) setUser(data);
} catch (err) {
if (!cancelled) setError(err.message);
}
}
load();
return () => { cancelled = true; };
}, [userId]);
if (error) return <p>Could not load this profile. {error}</p>;
if (!user) return <p>Loading…</p>;
return <h2>{user.name}</h2>;
}The alternative is AbortController, which cancels the request itself rather than ignoring its result:
useEffect(() => {
const controller = new AbortController();
fetch(url, { signal: controller.signal })
.then(res => res.json())
.then(setData)
.catch(err => {
if (err.name !== "AbortError") setError(err.message);
});
return () => controller.abort();
}, [url]);In a real project, a data-fetching library such as TanStack Query or a framework's built-in loader handles caching, retries, and race conditions for you. Writing fetches by hand is worth doing once, to understand what those libraries are doing.
Effects you do not need
The most common misuse is computing a value in an effect and storing it in state. If something can be worked out from existing props or state, calculate it during render instead.
// Unnecessary: an extra render and a value that can go stale
const [total, setTotal] = useState(0);
useEffect(() => {
setTotal(items.reduce((sum, i) => sum + i.price, 0));
}, [items]);
// Better: just derive it
const total = items.reduce((sum, i) => sum + i.price, 0);Similarly, code that should run in response to a click belongs in the click handler, not in an effect watching for the state that the click changed.
Why effects run twice in development
Strict Mode mounts each component, unmounts it, and mounts it again. An effect with correct cleanup survives this without trouble; one that leaks shows up immediately. It is a test, not a bug, and it does not happen in production builds.