AniUI Academy

Cleanup Functions in Effects

Returning a cleanup function from an effect to undo subscriptions, timers, and listeners — when React calls it, and why skipping it is a common source of memory leaks.

8 min read

Effects set things up — timers, subscriptions, listeners, in-flight requests. Nearly everything an effect sets up needs an equally explicit way to tear it back down, or it keeps running after it should have stopped.

Returning a cleanup function

If the function passed to useEffect returns another function, React treats that returned function as the effect's cleanup:

useEffect(() => {
  const id = setInterval(() => {
    console.log("tick");
  }, 1000);
 
  return () => {
    clearInterval(id);
  };
}, []);

The setup code runs first (starting the interval); the returned function is remembered and called later, when cleanup is needed — not run immediately.

When cleanup actually runs

Cleanup runs in two situations, and it's the same mechanism both times:

  1. Step 1

    Dependency changes

    Before the effect runs again with the new values, React first calls the previous run's cleanup function.

  2. Step 2

    Component unmounts

    React calls the most recent cleanup function one final time, since there won't be another run to precede.

Cleanup always runs before the next setup — including the case where 'the next setup' never comes, because the component is gone.

This means, for an effect with dependencies, cleanup and setup alternate: clean up the old subscription, then set up the new one, every time a dependency changes — not just at the very end of the component's life.

function ChatRoom({ roomId }) {
  useEffect(() => {
    const connection = connectToRoom(roomId);
 
    return () => {
      connection.disconnect();
    };
  }, [roomId]);
 
  return <p>Connected to {roomId}</p>;
}

Switch roomId from "general" to "random", and React disconnects from "general" (running the cleanup captured from that render) before connecting to "random" (running the new setup) — never leaving both connections open at once, and never leaving zero connections open in between.

What happens without cleanup

Skipping cleanup for anything that persists beyond a single render is the most common source of React memory leaks and duplicate work:

// No cleanup — leaks an interval every time this remounts, and never
// stops ticking even after the component is gone
useEffect(() => {
  setInterval(() => console.log("tick"), 1000);
}, []);

Every mount of this component starts a new interval that nothing ever stops — including after the component unmounts, since the interval has no idea the component is gone. The fix is always the same shape: capture whatever handle the setup returned (an id, a subscription object, a controller) and undo it in the returned cleanup function.

Cancelling in-flight requests

The same idea applies to network requests, where the risk isn't a leak so much as a race condition: a slow request that resolves after a newer one can overwrite fresh state with stale data.

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
 
  useEffect(() => {
    const controller = new AbortController();
 
    fetch(`/api/users/${userId}`, { signal: controller.signal })
      .then((res) => res.json())
      .then(setUser)
      .catch((err) => {
        if (err.name !== "AbortError") throw err;
      });
 
    return () => {
      controller.abort();
    };
  }, [userId]);
 
  return <p>{user?.name}</p>;
}

If userId changes again before the first request finishes, cleanup aborts it before starting the new one — so a stale response can never land after a fresher request has already started. The next lesson covers data fetching in effects in more depth, including why real applications often reach for a dedicated library rather than hand-writing this pattern everywhere.

Try it yourself

Watch the console as the room changes — notice the disconnect message always appears before the next connect message:

Try it yourself
Loading playground...

What to remember

  • Return a function from an effect to define its cleanup; React calls it, you don't call it yourself.
  • Cleanup runs before the effect's next setup on a dependency change, and one final time on unmount — the same mechanism both times.
  • Anything an effect starts that outlives a single render — timers, subscriptions, requests — needs matching cleanup, or it keeps running after it should have stopped.
  • AbortController in an effect's cleanup prevents a slow, stale request from overwriting fresher state — a common real-world race condition.

Check yourself

4 questions · pass 3/4 to unlock Fetching Data in an Effect

up to 50
  1. 1.How does an effect tell React how to undo whatever it set up?

  2. 2.For an effect with dependencies, when does its cleanup function run?

  3. 3.An effect starts setInterval(tick, 1000) but never returns a cleanup function. What goes wrong when the component unmounts?

  4. 4.What's a good use for an AbortController inside a data-fetching effect's cleanup?

4 left to answer