Course outline · 0% complete

0/28 lessons0%

Course overview →

Cleanup functions

lesson 7-2 · ~12 min · 20/28

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:

  1. Before re-running the effect because a dependency changed, old room closes before the new one opens.
  2. 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 withStopped with
setIntervalclearInterval
setTimeoutclearTimeout
addEventListenerremoveEventListener
a socket or subscriptionits 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.