Haliviq
React

useEffect cleanup running twice in Strict Mode isn't a bug to fix

In development, React 18 mounts, unmounts and re-mounts your component on purpose to surface effects that are not idempotent. If running it twice breaks something, such as a duplicate subscription or a double request, the effect was already unsafe. Fix the cleanup rather than turning Strict Mode off.

What happened

While building a screen in development, our useEffect ran twice on the first load. A subscription was opened two times and a request was sent twice, and our first instinct was to turn off Strict Mode to make it stop.

It turned out that Strict Mode was not causing the bug. It was only showing us one that already existed.

Why it happens

In development, Strict Mode in React 18 and later simulates a component being unmounted and mounted again: it runs the effect, then the cleanup, then the effect once more, with state preserved. This happens only in development and never in production builds.

The purpose is to check that every effect has a cleanup that fully undoes its setup. A correct effect behaves the same whether it runs once or goes through setup, cleanup, setup. A duplicate subscription means the cleanup was missing or incomplete, and the same bug would appear in production whenever the component is genuinely remounted, for example when a user navigates away and comes back.

What we do now

Write the cleanup so it reverses exactly what the effect did: unsubscribe, clear timers, close connections, abort fetches. For data fetching, use an AbortController or an ignore flag so a stale response is never applied. The extra request in development is then harmless, and a data library such as React Query or SWR can dedupe it for you.

Things that should happen once per user action, such as submitting an order or sending a purchase event, do not belong in an effect at all. They belong in an event handler.

An effect with a complete cleanup is safe to run twicetsx
useEffect(() => {
  const controller = new AbortController()

  fetch('/api/items', { signal: controller.signal })
    .then(res => res.json())
    .then(setItems)
    .catch(err => {
      if (err.name !== 'AbortError') console.error(err)
    })

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

Quick checklist

  1. 1Read the effect and list everything it sets up.
  2. 2Write a cleanup that undoes each item.
  3. 3Abort in-flight fetches with an AbortController.
  4. 4Move user-triggered actions into event handlers.
  5. 5Leave Strict Mode on.

Rule of thumb

If an effect breaks when it runs twice, fix its cleanup instead of turning Strict Mode off.

We'd love to hear what you're building.

Talk to us