Skip to content

React Tutorial React Hooks

useEffect Cleanup Functions Explained

Lesson 62 of 156 2 min read By Deepak Paswan Updated Oct 10, 2026

Runnable lesson · press Try it on the examples to edit and run them here.

Some effects start something that keeps going: a timer, an event listener, a subscription. If you never stop it, it leaks. The cleanup function is how you stop it.

What a cleanup function is

An effect can return a function. React calls it:

  1. Before running the effect again (when its dependencies changed), to undo the previous run.
  2. When the component is removed from the screen (unmounted).
App.jsx
JSX
function Timer() {
  const [seconds, setSeconds] = React.useState(0);

  React.useEffect(() => {
    const id = setInterval(() => setSeconds((s) => s + 1), 1000); // start
    return () => clearInterval(id);                                 // clean up
  }, []);

  return <p>Seconds: {seconds}</p>;
}

function App() {
  return <Timer />;
}

Without clearInterval, removing the Timer would leave the interval running and calling setSeconds on a component that no longer exists.

Show and hide to see the cleanup

App.jsx
JSX
function Timer() {
  React.useEffect(() => {
    const id = setInterval(() => console.log("tick"), 1000);
    return () => {
      clearInterval(id);
      console.log("timer cleaned up");
    };
  }, []);
  return <p>Timer is running</p>;
}

function App() {
  const [show, setShow] = React.useState(true);
  return (
    <div>
      {show ? <Timer /> : <p>Timer stopped</p>}
      <button onClick={() => setShow(false)}>Stop timer</button>
    </div>
  );
}

Press the button and look at the console panel: it prints timer cleaned up, and the ticking stops.

Event listeners

Always remove what you add:

App.jsx
JSX
function App() {
  const [width, setWidth] = React.useState(window.innerWidth);

  React.useEffect(() => {
    function handleResize() {
      setWidth(window.innerWidth);
    }
    window.addEventListener("resize", handleResize);
    return () => window.removeEventListener("resize", handleResize);
  }, []);

  return <p>Window width: {width}px</p>;
}

Drag the corner of the preview frame to resize it and the number updates. The same function reference is used for add and remove; a new arrow function would not be removed.

Cleanup also runs between effects

When a dependency changes, React runs the old cleanup, then the new effect. For a chat room: leave the old room, then join the new one.

App.jsx
JSX
const log = [];

function App() {
  const [room, setRoom] = React.useState("general");
  const [, force] = React.useState(0);

  React.useEffect(() => {
    log.push("joined " + room);
    force((n) => n + 1);
    return () => { log.push("left " + room); };
  }, [room]);

  return (
    <div>
      <p>Joined room {room}</p>
      <button onClick={() => setRoom("music")}>Go to music</button>
      <p>Log: {log.join(", ")}</p>
    </div>
  );
}

After the click, the log shows joined general, left general, joined music: the cleanup of the old room ran before the new room’s effect.

Why effects seem to run twice in development

In Strict Mode (on by default in the Vite template), React runs this sequence once, on mount, in development only: effect, then cleanup, then effect again. It simulates the component being removed and added back, to check that your cleanup properly undoes your setup. If your effect and cleanup are symmetrical, the double run is invisible; if not, you see a bug: two timers, two subscriptions, two connections.

This is a feature that tells you about a missing cleanup. It does not happen in production. Try it:

App.jsx
JSX
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";

const events = [];

function Demo() {
  const [, refresh] = React.useState(0);
  React.useEffect(() => {
    events.push("connect");
    return () => { events.push("disconnect"); };
  }, []);
  return (
    <div>
      <p>Strict Mode demo</p>
      <button onClick={() => refresh((n) => n + 1)}>Show log</button>
      <p>Events: {events.join(" > ")}</p>
    </div>
  );
}

createRoot(document.getElementById("root")).render(
  <StrictMode>
    <Demo />
  </StrictMode>
);

Press Show log and you will see connect > disconnect > connect. Remove StrictMode and run again to see the single connect. More in React Strict Mode.

Common mistakes

  • No cleanup for timers and listeners. They keep running and cause memory leaks and “state update on unmounted component” situations.
  • Different function references for addEventListener and removeEventListener.
  • Trying to “fix” the double run by removing Strict Mode. Fix the cleanup instead.
  • Cleanup that reads stale values. It sees the values of the render that created it, which is usually what you want.

Key points

  • Return a function from the effect to undo what it started.
  • It runs before the next effect and on unmount.
  • The development-only double run in Strict Mode exists to expose missing cleanups.