Topic 7.3
Cleaning Up Effects
In one line
The function an effect returns is its cleanup: it stops what the effect started (timers, subscriptions, event listeners, connections, requests), and React runs it before the effect runs again and when the component is removed.
Think of it like this
Borrowing a library book. When you take it out, you sign the card; when you are done, you return it. If you borrow a new book every week and never return any, your shelf overflows and the library runs out. An effect that starts something must return it too. The cleanup function is you returning the book.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Cleanup function
- The function you return from an effect. It stops or undoes what the effect started.
- Unmount
- React removing a component from the screen (and throwing away its state).
- Leak
- Something left running (a timer, a listener, a connection) after nothing needs it.
- Event listener
- A function the browser calls when an event happens, added with
addEventListener. - Subscription
- Asking a data source to call you whenever it changes. Unsubscribing stops the calls.
- Closure
- A function that remembers the variables around it when it was created.
- AbortController
- A browser object whose
abort()cancels afetchthat was given itssignal. - Interval
- A timer from
setIntervalthat runs a function again and again until it's cleared.
Step by step
01An effect that starts something
This effect starts an interval. Without cleanup, the interval keeps calling setTenths forever, even after the stopwatch is gone.
useEffect(() => {
setInterval(() => setTenths((t) => t + 1), 100); // never stopped!
}, []);02Return the undo
Keep the id the setup created, and return a function that uses it. The closure remembers id.
useEffect(() => {
const id = setInterval(() => setTenths((t) => t + 1), 100);
return () => clearInterval(id);
}, []);03Cleanup runs before the next setup
When a dependency changes, React cleans up the old effect (with the old values it captured) before running the new one. A chat room switching from general to travel disconnects from general first.
04Remove listeners with the same function
removeEventListener only removes a listener if you pass the exact same function object. Declare the handler inside the effect and use that name in both places.
useEffect(() => {
function handleResize() {
setWidth(window.innerWidth);
}
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);05Abort a request you no longer need
A request started for an old value should be cancelled when the value changes or the component leaves. AbortController does it; fetch rejects with an AbortError, which you ignore. Topic 7.7 builds this out fully.
useEffect(() => {
const controller = new AbortController();
fetch("/api/users/" + userId, { signal: controller.signal })
.then((res) => res.json())
.then(setUser)
.catch((err) => {
if (err.name !== "AbortError") setError(err);
});
return () => controller.abort();
}, [userId]);Try it yourself
- 1
Remove the cleanup
In the stopwatch example, delete the
return () => { ... }block. Predict the last log line. (A number above 0: the interval keeps ticking after the stopwatch is gone.) Then put it back. - 2
Use a new function in removeEventListener
In the key logger, change the cleanup to
window.removeEventListener("keydown", (e) => handleKey(e));. Predict whetherbis logged after removal. (It is: a new arrow function never matches the one that was added.) - 3
Switch rooms by hand
In the chat example, choose
music, thengeneral. Every switch prints a disconnect from the old room before the connect to the new one.
Code & diagrams
Hiding the stopwatch unmounts it, so React calls the cleanup and the interval stops: zero stray ticks. Click Show to start a fresh stopwatch (new state, new interval).
Preview
Console
Nothing logged yet.Expected console output
setup: start the interval
cleanup: clear the interval
ticks after it was removed: 0The scripted key presses go to `window`. After the logger is removed, pressing `b` logs nothing from it, because the cleanup removed the exact listener the setup added. Click inside the preview and press keys to try it.
Preview
Console
Nothing logged yet.Expected console output
listener added
key: a
listener removed
pressed b after removalEach cleanup closes over the connection of its own run, so switching to `travel` disconnects from `general` (the old value), not from `travel`. Closing the chat runs the last cleanup.
Preview
Console
Nothing logged yet.Expected console output
connect to #general
--- switch to travel
disconnect from #general
connect to #travel
--- close the chat
disconnect from #travelBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Forget to clear the interval
In the stopwatch example, remove the return with clearInterval, then let the demo hide the stopwatch.
Myth vs fact
Myth
Cleanup runs only when the component unmounts.
Fact
It also runs before every re-run of the effect, with the values from the previous run.
Myth
Unmounting a component stops its timers and listeners automatically.
Fact
React removes its own state and DOM. Timers, listeners, sockets and requests belong to the browser and run until you stop them in a cleanup.
Myth
Every effect needs a cleanup.
Fact
Only effects that start something continuing. Writing document.title has nothing to undo.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Cleanups of a commit run before any new setups of that commit: React first runs all cleanups for effects whose dependencies changed (and for unmounted components), then all new setups. So a removed component's disconnect happens before a newly added component's connect.
- ▸
Since React 17,
useEffectcleanups run asynchronously after the commit, not synchronously during unmount. Code in a cleanup must not assume the DOM nodes are still attached;useLayoutEffectcleanups run synchronously during the commit if you need that. - ▸
A cleanup sees the values from its own render (closures), which is exactly what you want for unsubscribing the right room. Reading
ref.currentin a cleanup is the exception: the ref may have changed, so copy it inside the effect first (the linter warns about this). - ▸
addEventListeneraccepts{ signal }from an AbortController: onecontroller.abort()in the cleanup removes many listeners at once and can also cancel fetches started in the same effect.
Remember this
- 1
Many effects start something that keeps going after the effect function finishes: an interval that ticks, an event listener on
window, a subscription to a data source (a function that calls you back when data changes), a connection to a server, a network request on its way. If nothing stops them, they keep running after they are needed. That's called a leak: memory, network and CPU used for nothing, and often visible bugs such as double messages. - 2
To stop them, the setup function returns a cleanup function:
useEffect(() => { start(); return () => stop(); }, [deps]). The cleanup is a closure (a function that remembers variables from where it was created, see /javascript closures), so it can see the timer id, the listener function or the connection object the setup created. - 3
When cleanup runs: (1) before the effect runs again, when a dependency changed: React first calls the old cleanup with the old values, then the new setup with the new values; (2) on unmount, when the component is removed from the screen. So setup and cleanup always come in pairs: setup, cleanup, setup, cleanup… and the last setup is cleaned up on unmount. In development StrictMode adds one extra pair at the start (Topic 7.4).
- 4
Each kind of side effect has a matching undo:
setTimeout→clearTimeout;setInterval→clearInterval;addEventListener(type, fn)→removeEventListener(type, fn)with the very same function (a new arrow function won't match);subscribe()→ theunsubscribefunction it returns;connection.connect()→connection.disconnect();fetch(url, { signal })→controller.abort()with an AbortController, the browser's way to cancel a request (full story in Topic 7.7). - 5
A good test of your cleanup: would the user notice the difference between setup → cleanup → setup and a single setup? If not, your cleanup is correct. That's exactly what StrictMode checks in development.
- 6
Not every effect needs a cleanup. Setting
document.titleor logging an analytics event leaves nothing running, so there's nothing to undo. If the effect starts nothing that continues, no cleanup is required.
Explain it without notes
When exactly does React run an effect's cleanup function?
Why does the cleanup in a chat room effect disconnect from the old room, not the new one?
List the cleanup that matches each of: setTimeout, setInterval, addEventListener, a subscription, fetch.
What happens if you forget a cleanup for an interval that calls a state setter? Does React warn?
Practice
Write a useEffect that logs tick every second while the component is on screen and stops on unmount.
Write an OnlineStatus component that shows Online or Offline using the window online and offline events, with a proper cleanup.
Trade-offs
- ↔
Tearing down and re-creating a subscription on every dependency change is simple and correct; for very expensive connections you may restructure (fewer dependencies, shared connections in a store) to re-sync less often.
Done when you can
I can write a cleanup for timers, listeners, subscriptions, connections and fetches.
I can say exactly when React runs cleanups.
I know cleanups see the values of their own render.
I remove event listeners with the same function I added.
I can spot a missing cleanup from its symptoms.