useEffect: dependencies and cleanup
An effect synchronises a component with something outside React. How the dependency array is compared, when cleanup runs, and why Strict Mode runs setup twice.
After this lesson you can
- Predict when an effect re-runs from its dependency array, using Object.is
- Order setup and cleanup calls across renders and unmount
- Explain why an object or function dependency makes an effect run on every render
- Explain what Strict Mode's extra setup and cleanup in development is checking
Rendering must be pure, but components still need to talk to the world outside React: open a socket, subscribe to an event, start a timer, sync a third-party widget. That is what an effect is for.
function ChatRoom({ roomId }: { roomId: string }) {
useEffect(() => {
const conn = createConnection(roomId);
conn.connect();
return () => conn.disconnect(); // cleanup
}, [roomId]); // dependencies
return <h1>Welcome to {roomId}</h1>;
}
The function is the setup. React runs it after the render has been
committed to the screen, so the DOM is up to date by then. (If you must
measure or change layout before the browser paints, useLayoutEffect
runs earlier, at the cost of blocking paint.) The function it returns is
the cleanup.
The dependency array
After every render, React compares each dependency with the value it had
on the previous render, one by one, using Object.is. The effect runs
again only if at least one changed.
- No array at all: the effect runs after every render.
[]: it runs after the first render only.[roomId]: it runs after the first render and wheneverroomIdchanges.
function shouldRun(prevDeps, nextDeps) { if (prevDeps === null || nextDeps === null) return true; // no array: every render for (let i = 0; i < nextDeps.length; i++) { if (!Object.is(prevDeps[i], nextDeps[i])) return true; } return false;} export function run() { const handler = () => {}; return { "[1, 'a'] then [1, 'a']": shouldRun([1, "a"], [1, "a"]), "[1, 'a'] then [2, 'a']": shouldRun([1, "a"], [2, "a"]), "[] then []": shouldRun([], []), "no array": shouldRun(null, null), "[{ id: 1 }] then [{ id: 1 }]": shouldRun([{ id: 1 }], [{ id: 1 }]), "[() => {}] then [() => {}]": shouldRun([() => {}], [() => {}]), "same function both times": shouldRun([handler], [handler]), "[NaN] then [NaN]": shouldRun([NaN], [NaN]), };}The comparison is by identity, which is the source of the most common
effect bug: an object, array or function created during render is a new
value every render, so an effect depending on it runs every time. Depend
on primitive values (options.roomId rather than options), move the
object inside the effect, or memoise it (lesson 12).
The dependency list is not a choice you make. It must contain every
reactive value — props, state, and anything computed from them — that the
effect reads. The react-hooks/exhaustive-deps lint rule checks this. If
you want an effect to run less often, change the code so it reads fewer
values, never lie in the array.
When cleanup runs
Cleanup runs before the effect runs again — with the values from the render that set it up — and once more when the component unmounts. That pairing is what keeps a component from leaving connections open:
// A tiny effect runner for one effect with deps [roomId].function mountChatRoom() { const log = []; let prevDeps = null; let cleanup = null; return { log, render(roomId) { const deps = [roomId]; const changed = prevDeps === null || deps.some((d, i) => !Object.is(d, prevDeps[i])); if (!changed) return; if (cleanup) cleanup(); log.push(`connect ${roomId}`); cleanup = () => log.push(`disconnect ${roomId}`); prevDeps = deps; }, unmount() { if (cleanup) cleanup(); }, };} export function run() { const room = mountChatRoom(); room.render("general"); room.render("general"); room.render("travel"); room.unmount(); return room.log;}The second render had the same roomId, so nothing happened. When the
room changed, React disconnected from the old room before connecting to
the new one. On unmount, the last connection was closed.
Strict Mode runs setup twice
In development, <StrictMode> mounts each component, immediately runs
every effect's cleanup, then runs the setup again. Production does not.
It is a stress test: if setup → cleanup → setup behaves differently from a
single setup, the cleanup is missing or incomplete, and real users would
hit the same bug when navigating away and back. The fix is a correct
cleanup, not disabling Strict Mode or guarding with a "has run" ref.
Common mistakes
- An object or function in the dependency array that is created
during render, so the effect re-runs after every render. The
{ id: 1 }line in the first example is the reason. - Leaving out a dependency to "run it once". The effect keeps reading the values from the first render (a stale closure) and silently disagrees with the screen.
- No cleanup for a subscription or timer. Each run adds another listener; Strict Mode makes the duplicate visible straight away.
- Setting state from an effect to compute a value from props. That renders twice for nothing; compute it during render (next lesson).
What interviewers ask
- How does React decide whether to re-run an effect? — It compares each
dependency with its previous value using
Object.is; no array means every render,[]means only after mount. - When does the cleanup function run? — Before the next run of the effect, with the previous render's values, and when the component unmounts.
- — Strict Mode mounts, cleans up and sets up again to prove the cleanup undoes the setup; it does not happen in production.
Exercise · Pro
Count the open connections
Exercises are free in the first 3 lessons of every course. From here on they are part of Pro and Sprint; the lesson and its runnable examples stay open.
Upgrade