Skip to content
React rc effects 4 min read

Cleanup Functions

Many effects don’t just do something — they start something that keeps running: a subscription, a timer, an event listener, an open socket. If you never stop those, they pile up every time the effect re-runs or the component unmounts, leaking memory and producing duplicate work. React solves this with cleanup functions: the function you optionally return from an effect, which React calls to tear down whatever the effect set up.

Returning a cleanup function

An effect can return a function. React remembers it and runs it at the right moment — before the effect runs again, and once more when the component unmounts. Everything you connect, open, or subscribe to inside the effect should be disconnected, closed, or unsubscribed inside the cleanup.

import { useEffect, useState } from 'react';

function Clock() {
  const [time, setTime] = useState(() => new Date());

  useEffect(() => {
    const id = setInterval(() => {
      setTime(new Date());
    }, 1000);

    // Cleanup: stop the timer
    return () => clearInterval(id);
  }, []);

  return <p>{time.toLocaleTimeString()}</p>;
}

Without the clearInterval, navigating away from Clock and back would leave the old interval running forever, calling setState on an unmounted component and stacking up more timers each time.

When cleanup runs

The mental model is simple: for every time the effect runs, React runs the matching cleanup before running it again, and one final time on unmount. The order is always cleanup of the previous run, then the new run.

Lifecycle momentSetup runs?Cleanup runs?
First mountYesNo
Re-render, dependencies changedYes (after cleanup)Yes (previous setup)
Re-render, dependencies unchangedNoNo
UnmountNoYes (last setup)

This is why cleanup pairs naturally with dependencies: when a dependency like userId changes, React cleans up the subscription for the old user before subscribing for the new one.

import { useEffect, useState } from 'react';

function UserStatus({ userId }) {
  const [online, setOnline] = useState(false);

  useEffect(() => {
    console.log('Subscribing to', userId);
    const handle = (status) => setOnline(status);
    chatAPI.subscribe(userId, handle);

    return () => {
      console.log('Unsubscribing from', userId);
      chatAPI.unsubscribe(userId, handle);
    };
  }, [userId]);

  return <span>{online ? 'Online' : 'Offline'}</span>;
}

Output:

Subscribing to 7        // mount with userId=7
Unsubscribing from 7    // userId changes to 9
Subscribing to 9
Unsubscribing from 9    // component unmounts

Notice there is never an active subscription to two users at once. Each old subscription is cleaned up before the next is created.

Cleaning up event listeners

The same pattern applies to addEventListener. Add the listener in setup, remove the same function reference in cleanup.

import { useEffect, useState } from 'react';

function WindowWidth() {
  const [width, setWidth] = useState(window.innerWidth);

  useEffect(() => {
    const onResize = () => setWidth(window.innerWidth);
    window.addEventListener('resize', onResize);

    return () => window.removeEventListener('resize', onResize);
  }, []);

  return <p>Window is {width}px wide</p>;
}

Always remove the exact function you added. Passing an inline arrow to both addEventListener and removeEventListener creates two different functions, so the listener is never actually removed.

Why Strict Mode double-invokes effects

In development, React 18+ Strict Mode mounts each component, then immediately runs cleanup and setup again. This is intentional: it surfaces missing cleanup. If your effect is correctly paired with a cleanup, the double-run is invisible — subscribe, unsubscribe, subscribe leaves you with exactly one live subscription.

Subscribing to 7
Unsubscribing from 7   // Strict Mode tears down...
Subscribing to 7       // ...and re-runs. Still one subscription.

If you ever see duplicate intervals, doubled network connections, or two listeners firing, that’s Strict Mode telling you a cleanup is missing — not a bug to suppress.

Cleaning up async work

You can’t await directly in an effect callback, and you can’t “cancel” a promise. Instead, use a flag (or an AbortController) so a stale async result is ignored after cleanup.

import { useEffect, useState } from 'react';

function Profile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    const controller = new AbortController();

    async function load() {
      try {
        const res = await fetch(`/api/users/${userId}`, {
          signal: controller.signal,
        });
        setUser(await res.json());
      } catch (err) {
        if (err.name !== 'AbortError') throw err;
      }
    }

    load();
    return () => controller.abort();
  }, [userId]);

  return <pre>{JSON.stringify(user, null, 2)}</pre>;
}

Aborting on cleanup prevents a slow response for an old userId from overwriting fresher data — the classic race condition.

Best Practices

  • Return a cleanup whenever your effect creates something ongoing: timers, intervals, subscriptions, listeners, sockets, or in-flight requests.
  • Mirror setup and cleanup symmetrically — every subscribe has an unsubscribe, every addEventListener a matching removeEventListener.
  • Capture handles (interval ids, abort controllers, listener references) in the effect body so the cleanup closure can reach them.
  • Treat Strict Mode’s double-invocation as a free correctness check; fix missing cleanup rather than disabling it.
  • For data fetching, use AbortController or an ignore flag in cleanup to drop stale results and avoid race conditions.
  • Keep cleanup synchronous and fast — it runs during render commits and on unmount.
Last updated June 14, 2026
Was this helpful?