Command Palette

Search for a command to run...

Hectal
PHASE 7Intermediate React 16.8+ ~22 min· topic 3 of 9

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 a fetch that was given its signal.
Interval
A timer from setInterval that 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.

Stopwatch.jsxwhole filejsx
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.

Stopwatch.jsxwhole filejsx
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.

Cleanup runs before the next setupdiagram
Rendering diagram…

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.

useWindowWidth.jsxwhole filejsx
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.

Profile.jsxwhole filejsx
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. 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. 2

    Use a new function in removeEventListener

    In the key logger, change the cleanup to window.removeEventListener("keydown", (e) => handleKey(e));. Predict whether b is logged after removal. (It is: a new arrow function never matches the one that was added.)

  3. 3

    Switch rooms by hand

    In the chat example, choose music, then general. Every switch prints a disconnect from the old room before the connect to the new one.

Code & diagrams

Clear the interval when the stopwatch leaves New tab

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

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

setup: start the interval
cleanup: clear the interval
ticks after it was removed: 0
Add and remove a window listener New tab

The 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

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

listener added
key: a
listener removed
pressed b after removal
Switching rooms: cleanup, then the new setup New tab

Each 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

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

connect to #general
--- switch to travel
disconnect from #general
connect to #travel
--- close the chat
disconnect from #travel

Break 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.

terminal
$ Console
── what you'll see ──
setup: start the interval
ticks after it was removed: 4
(the exact number depends on timing; any number above 0 is the leak)

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, useEffect cleanups run asynchronously after the commit, not synchronously during unmount. Code in a cleanup must not assume the DOM nodes are still attached; useLayoutEffect cleanups 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.current in a cleanup is the exception: the ref may have changed, so copy it inside the effect first (the linter warns about this).

  • ▸

    addEventListener accepts { signal } from an AbortController: one controller.abort() in the cleanup removes many listeners at once and can also cancel fetches started in the same effect.

Remember this

  1. 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. 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. 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. 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() → the unsubscribe function 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. 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. 6

    Not every effect needs a cleanup. Setting document.title or 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

01

When exactly does React run an effect's cleanup function?

02

Why does the cleanup in a chat room effect disconnect from the old room, not the new one?

03

List the cleanup that matches each of: setTimeout, setInterval, addEventListener, a subscription, fetch.

04

What happens if you forget a cleanup for an interval that calls a state setter? Does React warn?

Practice

01

Write a useEffect that logs tick every second while the component is on screen and stops on unmount.

02

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.