Skip to main content

Effects and Refs

Effect Cleanup

Written by Published

Whatever an effect starts, its cleanup must stop. React runs cleanup before the next effect and again when the component unmounts.

Returning a cleanup function

If an effect returns a function, React calls it before running the effect again and when the component leaves the tree. Anything ongoing - a timer, a listener, a subscription - needs one.

A timer that stops

jsx

function Ticker() {
  const [seconds, setSeconds] = React.useState(0)

  React.useEffect(() => {
    const id = setInterval(() => setSeconds((s) => s + 1), 1000)

    // Without this, the interval keeps firing after unmount.
    return () => clearInterval(id)
  }, [])

  return <p>Running for {seconds}s</p>
}

function Demo() {
  const [show, setShow] = React.useState(true)
  return (
    <div>
      <button onClick={() => setShow((s) => !s)}>{show ? "Unmount" : "Mount"} the ticker</button>
      {show && <Ticker />}
    </div>
  )
}

const root = ReactDOM.createRoot(document.getElementById("root"))
root.render(<Demo />)

Without the clearInterval, unmounting would leave the timer running and calling a setter on a component that no longer exists - a leak that survives until the page is closed.

Event listeners

Add and remove in pairs

jsx

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

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

  return <p>Preview width: {width}px — resize the panel to see it change.</p>
}

const root = ReactDOM.createRoot(document.getElementById("root"))
root.render(<WindowWidth />)

The same function reference must be passed to both addEventListener and removeEventListener, which is why it is named rather than inlined twice.

Ignoring a stale response

You cannot un-send a network request, but you can decide to ignore its answer. Without this, a slow first request can land after a fast second one and overwrite newer data with older.

The ignore flag

jsx

function Loader({ id }) {
  const [text, setText] = React.useState("idle")

  React.useEffect(() => {
    let ignore = false
    setText("loading…")

    // Stand-in for a fetch: later ids resolve faster.
    const delay = id === 1 ? 1200 : 200
    const timer = setTimeout(() => {
      if (!ignore) setText(`data for id ${id}`)
    }, delay)

    return () => {
      ignore = true
      clearTimeout(timer)
    }
  }, [id])

  return <p>{text}</p>
}

function Demo() {
  const [id, setId] = React.useState(1)
  return (
    <div>
      <button onClick={() => setId(id === 1 ? 2 : 1)}>Switch id (now {id})</button>
      <Loader id={id} />
      <p style={{ color: "#64748b" }}>
        id 1 is slow. Switch quickly to 2 — the stale result is discarded.
      </p>
    </div>
  )
}

const root = ReactDOM.createRoot(document.getElementById("root"))
root.render(<Demo />)

The double run in development

In development React mounts, unmounts and remounts each component once, so every effect runs twice and its cleanup runs in between. This is deliberate: an effect that breaks under it has a missing or incorrect cleanup.

It does not happen in production. Treat it as a test rather than a bug - if a doubled effect causes a problem, the cleanup is what needs fixing.

  • Return a function from the effect to clean up.
  • Cleanup runs before the next effect and on unmount.
  • Pair every subscribe with an unsubscribe.
  • Use an ignore flag for requests you cannot cancel.