AcademyReact
Dependencies and Cleanup in useEffect
An Effect synchronizes with something outside React. The dependency array says what it reads, not when to run — get that backwards and you get stale values.
4 min readUpdated 3 exercises
An Effect is not "run this after render"
That is the mental model almost everyone starts with, and it is wrong often enough to matter. An Effect's job is narrower: synchronize the component with something React does not manage — a subscription, a setInterval, a DOM measurement, a fetch keyed off some value in scope. If a computation only touches props and state and produces a value the render needs, it belongs in the render itself (or in useMemo), not in an Effect that sets more state and triggers a second render.
function ChatRoom({ roomId }) {
useEffect(() => {
const connection = createConnection(roomId);
connection.connect();
return () => connection.disconnect();
}, [roomId]);
return <p>Connected to {roomId}</p>;
}There is no React state involved in createConnection at all — the Effect exists purely to keep an external system (the socket) in sync with a React value (roomId). That is the shape every legitimate Effect has: connect this outside thing to that React value, and keep them in sync as the value changes.
The dependency array answers a different question than people assume
The instinct is to read [roomId] as "run this effect when roomId changes" — a scheduling instruction. React actually treats it as a much more literal claim: these are the reactive values this Effect's code reads from the render it belongs to. React re-runs the Effect whenever any of them differs from the previous render, using Object.is per entry, because the closure captured in the previous render is now referencing stale data.
This reframing is what makes the ESLint exhaustive-deps rule make sense instead of feeling like busywork. It is not enforcing a style preference — it is checking that every reactive value your Effect's closure touches is declared, because an undeclared one is a value the Effect will keep reading from the render where it was created, forever, until something else happens to re-trigger the Effect for an unrelated reason.
// Bug: closes over the FIRST render's count and never sees a new one
useEffect(() => {
const id = setInterval(() => {
console.log(count); // always logs 0
}, 1000);
return () => clearInterval(id);
}, []); // count is read here but missing from the arraycount is read inside the Effect, so it is a reactive value the Effect depends on — omitting it from the array does not stop the closure from capturing it, it just stops React from re-running the Effect (and thus re-creating the closure) when it changes. The fix is not "add a comment suppressing the lint rule"; it is either declaring the dependency or restructuring so the Effect does not need the stale value at all:
// Fixed: functional update reads the CURRENT state, not a captured one
useEffect(() => {
const id = setInterval(() => {
setCount((c) => c + 1); // no closure over count needed
}, 1000);
return () => clearInterval(id);
}, []); // genuinely has no reactive dependency nowsetCount(c => c + 1) never reads count from the closure — it asks React for whatever the state is at the moment the updater runs. The dependency array being empty here is correct, not a workaround, because nothing reactive is actually read inside the Effect anymore.
Cleanup undoes exactly what the Effect set up
The function an Effect can return is not a generic "unmount hook." It runs before the Effect re-runs (once per re-synchronization, right before the next connect) and once more when the component unmounts — and its only job is to undo whatever the Effect just did, so the next run starts clean instead of stacking side effects on top of each other.
useEffect(() => {
function handleResize() {
setWidth(window.innerWidth);
}
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);Without the cleanup, a component that mounts and unmounts repeatedly (a modal opened three times, a route visited and left) leaves one orphaned listener behind per mount — each one still holding a reference to a component instance that no longer exists, which is a textbook memory leak and, in development with Strict Mode's mount-unmount-remount cycle, an immediately visible bug rather than a slow one.
The pairing to check for, always: whatever the Effect subscribes to, starts, or opens, the cleanup unsubscribes from, stops, or closes. addEventListener / removeEventListener, setInterval / clearInterval, .connect() / .disconnect() — the Effect and its cleanup are two halves of one operation, not two unrelated pieces of code that happen to live in the same call.
| Effect does | Cleanup undoes |
|---|---|
addEventListener | removeEventListener |
setInterval / setTimeout | clearInterval / clearTimeout |
| open a socket / subscribe | close it / unsubscribe |
| start a fetch | ignore the response if it lands after cleanup ran |
That last row is the one people miss: a cleanup cannot cancel a promise already in flight, but it can set a flag the .then checks before calling setState, which is how you avoid setting state from a fetch that belongs to a stale roomId.
Now practice it
2 challenges · 1 quizReading this page is the cheap half. These are the exercises that make you use it.
- depsChanged — useEffect DepsProImplements the equality check React itself runs per dependency array entry to decide whether an Effect re-fires.ChallengeSolve · EasyChallengeSolve · Easy
- useEffect Cleanup OrderProForces you to reason about exactly when cleanup runs relative to the next Effect and to unmount, not just that it eventually does.ChallengeSolve · HardChallengeSolve · Hard
- Fundamentals · Advanced PatternsEffect timing and dependency questions at the difficulty where "it depends on render order" stops being a vague answer.QuizTake · HardQuizTake · Hard
Read next
Refs and Imperative DOM Access
useRef is a mutable box that survives re-renders without causing them — the escape hatch for the DOM and for values that are not part of render.
3 min
Writing Custom Hooks
A custom hook is a plain function that calls other hooks. It shares logic, not state — two components using it get two independent copies.
5 min