The useEffect Cleanup Function — the Art of Undoing What You Turned On
When and why useEffect's cleanup function runs, laid out as a lifecycle. Including the real reason behind the commonly-misunderstood 'double run in development' (StrictMode).
Last time we said, “if you start a timer or connect to a server, you need to turn it off when the component goes away.” The thing in charge of that turning off is the cleanup function. The concept itself is simple, but its timing is confusing — which makes it the single biggest source of confusion in useEffect. Today let’s nail down that timing.
What is a cleanup function?
A cleanup function is the function that the setup function returns. That’s the whole syntax.
useEffect(() => {
const connection = createConnection(roomId);
connection.connect(); // setup: connect
return () => connection.disconnect(); // cleanup: disconnect
}, [roomId]);
The function after return is the cleanup function. As the name says, it undoes whatever the setup function set in motion (connections, subscriptions, timers, and so on). The key intuition is that “the cleanup function is the setup function’s pair.” If setup turns something on, cleanup turns it off; if setup subscribes, cleanup unsubscribes.
When does it run — the three moments
React calls setup and cleanup whenever needed, which comes down to three moments:
- Mount (the component first appears on screen): the setup function runs.
- When a dependency changes and it re-runs: first the cleanup runs with the old values, then the setup runs with the new values.
- Unmount (the component disappears from screen): the cleanup runs one final time.
Number 2 is especially important. In the example above, when roomId changes from room 1 to room 2, React works in the order “disconnect from room 1 → connect to room 2.” Because it cleans up the old connection before making the new one, you don’t get the accident of the old connection lingering and piling up when you switch rooms.
So the cleanup function doesn’t run “only when the component dies” — it also runs right before each time setup re-runs, to undo the old one.
Why it runs twice in development
Here’s where many people get badly confused. In a development environment, when you make an effect, setup appears to be called twice on mount (setup → cleanup → setup). Add a log and it looks like initialization runs twice, which is alarming.
This is not a bug, and not due to JavaScript’s async behavior. It’s React’s StrictMode deliberately running setup→cleanup→setup one extra time, in development only. The purpose is singular: a stress test to surface effects that are missing or have a broken cleanup function early.
The logic goes like this. If your cleanup perfectly undoes your setup, then “setup called once (production)” and “setup→cleanup→setup (development)” should be indistinguishable to the user. If the double run causes a visible problem (a duplicate connection, an event registered twice, etc.), that’s React telling you: your cleanup function is missing some logic.
So the development double-run isn’t something to eliminate — it’s an opportunity to have your cleanup logic verified. In a production build the double run disappears and setup runs only once.
So here’s an easy way to see the cleanup function
See the cleanup function as “an occasional tidy-up you sometimes need” and its timing will keep confusing you. Instead, see it this way: every setup function should come as a pair with a cleanup function that undoes it. Connect, then disconnect; subscribe, then unsubscribe; set a timer, then clear it — keep only this symmetry, and no matter when React runs the pair (before a re-run, on unmount, or in the development double-run), it always behaves correctly.
We’ve now come through State and Effect. Next time we’ll look at a value with a different character from state — one that doesn’t redraw the screen when it changes, yet remembers its value across renders: useRef.