Cleanup functions
Some effects start something that must later be stopped: an interval keeps ticking, a subscription keeps listening, a websocket stays open. If the component unmounts or the dependencies change, the old work must be shut down. Otherwise it leaks: the timer or subscription keeps consuming memory and CPU for a component that no longer exists, and if its callback calls a state setter, React warns about updating an unmounted component.
The contract: whatever the effect returns is its cleanup function.
function ChatRoom({ room }) { useEffect(() => { const conn = connect(room); // start return () => conn.close(); // stop }, [room]); return <h1>Room: {room}</h1>; }
React runs cleanup at two moments:
- Before re-running the effect because a dependency changed, old room closes before the new one opens.
- At unmount, when the component leaves the screen.
So switching from room general to room random produces: subscribe general → unsubscribe general → subscribe random.
The cleanup contract in plain JavaScript
effect(room) starts a subscription and returns its own shutdown function, exactly like a useEffect callback does.
function effect(room) { console.log("effect: subscribe to " + room); return () => console.log("cleanup: unsubscribe from " + room); } let cleanup = effect("general"); // mount, deps = ["general"] cleanup(); // deps changed: clean old first cleanup = effect("random"); // then run effect for new deps cleanup(); // unmount: final cleanup
Output
effect: subscribe to general cleanup: unsubscribe from general effect: subscribe to random cleanup: unsubscribe from random
The four lines map to three moments: mount, a dependency change, and unmount. Only the dependency change produces both a cleanup and an effect, which is why the middle of the output has two lines for one event.
Old cleanup always runs before the next effect, so there is never a moment with two live subscriptions. That ordering is a guarantee rather than a coincidence, and it is what makes the pattern safe for connections that a server counts.
The reason the shutdown function knows which room to close is closure, from Advanced JavaScript. Each call to effect creates a fresh room binding that its returned function captures, so the cleanup for general closes general even after random has been subscribed.
Note that cleanup here is a variable holding a function, and React holds the same thing internally per effect per component. Reassigning it after calling it mirrors React replacing the stored cleanup with the one from the newest run.
How React decides whether to re-run
One dep value arrives per render, as ["a", "b", "b", "c"], and the repeated "b" must be skipped.
function effect(dep) { console.log("run effect for " + dep); return () => console.log("clean up " + dep); } const renders = ["a", "b", "b", "c"]; let hasPrev = false; let prev = null; let cleanup = null; for (const dep of renders) { const changed = !hasPrev || !Object.is(prev, dep); if (changed) { if (cleanup) cleanup(); cleanup = effect(dep); prev = dep; hasPrev = true; } }
Output
run effect for a clean up a run effect for b clean up b run effect for c
Two cases re-run the effect: the very first render, where hasPrev is false, or a render where the value differs from the previous one. changed = !hasPrev || !Object.is(prev, dep) is those two cases in one expression.
The first-render case needs its own flag because there is no previous value to compare against. Using prev === null instead would misfire the moment a real dependency is legitimately null, which is common for a "nothing selected yet" state.
Four renders produce three effect runs, because the second "b" compares equal and is skipped. That skip is the entire reason dependency arrays exist, since without it every render would tear down and rebuild every subscription.
Object.is is the same identity comparison from lesson 4-3, so all the rules from there apply. A mutated object looks unchanged and skips the effect, and a freshly built object looks changed and re-runs it, which is how a {} or [] written inline in JSX turns into an effect that fires on every render.
Note the sequencing inside the if, which cleans up before running. Swapping those two lines would build the new subscription first and then close the old one, and for anything that holds a connection or a lock that overlap is exactly the bug the contract prevents.
An interval with no cleanup
useEffect(() => { const id = setInterval(tick, 1000); }, []);
The bug is that nothing returns a cleanup, so the interval keeps running after unmount, which is a memory leak that can also call a state setter on a component that no longer exists.
The effect starts an interval and never stops it, so after unmount tick keeps firing once a second for as long as the page is open. Navigating between pages ten times leaves ten intervals running.
The fix is one line, return () => clearInterval(id);, and the id is already captured for it. Any effect that starts something ongoing needs a cleanup, and the pairs are worth memorizing:
| Started with | Stopped with |
|---|---|
setInterval | clearInterval |
setTimeout | clearTimeout |
addEventListener | removeEventListener |
| a socket or subscription | its own close or unsubscribe |
The symptom in development is usually a console warning about updating state on an unmounted component, since tick almost always ends in a setter. In production the visible version is a page that gets slower the longer someone uses the app, because the leaked work accumulates.
Note that the empty dependency array is correct here and is not the problem. The effect genuinely should start once, and the missing piece is the shutdown half of the contract rather than the deps.