useEffect
useEffect lets a component reach outside of React to synchronize with systems that React does not control — the browser DOM, network requests, timers, subscriptions, or third-party widgets. React’s job is to render UI from state; an effect is the escape hatch for everything else that has to happen as a result of that render. Mastering when an effect runs, what its dependency array means, and how cleanup works is the difference between code that quietly leaks subscriptions and code that stays in sync.
Effects run after the commit
When a component renders, React calculates the new UI and commits it to the DOM. Only after the browser has updated the screen does React run your effect. This timing is deliberate: the user sees the rendered output first, and side effects happen afterward without blocking the paint.
import { useState, useEffect } from "react";
function Title() {
const [count, setCount] = useState(0);
useEffect(() => {
document.title = `Clicked ${count} times`;
console.log("effect ran with count =", count);
});
return <button onClick={() => setCount((c) => c + 1)}>+1</button>;
}
Output:
effect ran with count = 0
effect ran with count = 1
effect ran with count = 2
The effect runs once after the first render, then again after every render that changes count. With no dependency array at all, it re-runs after every commit.
The dependency array
The second argument to useEffect is the dependency array. It tells React which reactive values the effect reads, so React can decide whether to re-run it. React compares each dependency to its previous value with Object.is; if all of them are unchanged, the effect is skipped.
| Second argument | When the effect runs |
|---|---|
| omitted | After every render |
[a, b] | After the first render, then whenever a or b changes |
[] | Once, after the first render only |
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
let active = true;
setUser(null);
fetch(`https://jsonplaceholder.typicode.com/users/${userId}`)
.then((res) => res.json())
.then((data) => {
if (active) setUser(data);
});
return () => {
active = false;
};
}, [userId]);
if (!user) return <p>Loading…</p>;
return <h1>{user.name}</h1>;
}
Because userId is the only dependency, the fetch re-runs only when the prop changes — not on unrelated re-renders.
Every reactive value your effect reads (props, state, and values derived from them) must appear in the dependency array. The
react-hooks/exhaustive-depsESLint rule enforces this. Trimming the array to “fix” extra runs causes stale-closure bugs instead — change what the effect reads, not what you declare.
Cleanup functions
An effect can return a function. React calls it to clean up before the effect runs again, and once more when the component unmounts. This is how you tear down anything the effect set up: timers, event listeners, subscriptions, sockets.
function WindowWidth() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
const onResize = () => setWidth(window.innerWidth);
window.addEventListener("resize", onResize);
return () => {
window.removeEventListener("resize", onResize);
};
}, []);
return <p>Window is {width}px wide</p>;
}
The lifecycle for an effect with dependencies is: setup → (dependency changes) → cleanup → setup → … → cleanup on unmount. Pairing every subscription with a matching cleanup is what prevents memory leaks and duplicate listeners.
In development, React 18+ Strict Mode mounts each component twice on purpose — running setup, cleanup, then setup again — to surface effects that forgot to clean up. If your effect is correct, the double-invoke is invisible in behavior.
Common uses
- Fetching data tied to a prop or state value (with an
activeflag orAbortControllerto ignore stale responses). - Subscribing to a store, WebSocket, or browser event, with cleanup that unsubscribes.
- Imperative DOM work React doesn’t manage — focusing an input, syncing a
<canvas>, integrating a chart library. - Timers via
setInterval/setTimeout, cleaned up so they don’t fire after unmount.
You might not need an Effect
Effects are overused. A large class of code that people put in useEffect belongs elsewhere:
- Transforming data for render — compute it during render (or with
useMemo), not in an effect that writes to extra state. - Responding to a user event — put the logic in the event handler, where you know exactly what happened.
- Resetting state when a prop changes — usually a
keyon the component does it more cleanly.
// ❌ Unnecessary effect to derive state
function Cart({ items }) {
const [total, setTotal] = useState(0);
useEffect(() => {
setTotal(items.reduce((sum, i) => sum + i.price, 0));
}, [items]);
return <p>Total: {total}</p>;
}
// ✅ Just calculate during render
function Cart({ items }) {
const total = items.reduce((sum, i) => sum + i.price, 0);
return <p>Total: {total}</p>;
}
The deriving version triggers an extra render and can show a stale total for one frame. The plain calculation is simpler and always correct.
Best Practices
- Use the narrowest dependency array that is still complete — never silence the linter by omitting deps.
- Always return a cleanup function from effects that subscribe, time, or open anything.
- Keep one concern per effect; split unrelated logic into separate
useEffectcalls. - For data fetching, guard against race conditions with an
activeflag orAbortController. - Ask “is this a side effect at all?” — derive values during render and handle user actions in event handlers instead.
- Use
useLayoutEffectonly when you must measure or mutate the DOM before paint; otherwise preferuseEffect. - Treat Strict Mode’s double-run in development as a feature that exposes missing cleanup.