12k
All articles

Why Your useEffect Runs Twice

See why React StrictMode makes useEffect run twice in development and how to fix fetches, listeners, and other effects with proper cleanup.

OpenReplay Team
OpenReplay Team
Why Your useEffect Runs Twice

In development, React StrictMode runs each effect’s setup, then its cleanup, then its setup again, so any effect that fetches, subscribes or logs will visibly happen twice.

So you write one fetch in an effect, open the Network tab, and there are two identical requests and two console logs. It looks like a bug in your code or in React.

It isn’t either one. The double run is a deliberate test of your cleanup logic. This article covers what StrictMode is doing, how to fix the three most common effects that fail the test, which “fixes” only hide the problem, and how to tell when you’re done. If you need a refresher on the hook itself, start with this guide to React’s useEffect hook.

Key Takeaways

  • StrictMode runs one extra setup and cleanup cycle for every effect, only in development, in both React 18 and React 19.
  • StrictMode is opt-in, but the Vite React template wraps the app in <StrictMode>, and the Next.js App Router turns it on by default.
  • A correct cleanup function undoes exactly what the setup did: abort the request, remove the listener, clear the timer or close the connection.
  • Two requests in the Network tab during development are expected even after a correct fix. What matters is that only the latest response can update state.
  • Removing StrictMode or adding a useRef “has run” guard hides missing cleanup without fixing it.

The Symptom: A Fetch That Fires Twice

A data-fetching effect with no cleanup is the most common way people discover this behavior. This component runs fine in production and fires twice in development:

import { useState, useEffect } from 'react';

function ProductDetails({ id }) {
  const [product, setProduct] = useState(null);

  useEffect(() => {
    console.log('setup');
    fetch(`/api/products/${id}`)
      .then((res) => res.json())
      .then((data) => setProduct(data));
  }, [id]);

  return <h2>{product?.name}</h2>;
}

You get two setup logs and two requests. Nothing tells React how to cancel the first request, so both run to completion.

Why Does useEffect Run Twice in Development?

The React StrictMode reference explains that, with StrictMode on, React adds one more setup and cleanup pass to every effect during development. Your effect runs setup, then cleanup, then setup again, as if the component mounted, unmounted and mounted again straight away.

The double run happens only in development builds. In production, an effect runs once when the component mounts and again only when its dependencies change or the component remounts.

StrictMode is opt-in. It applies only to components wrapped in <StrictMode>. Many projects are wrapped by default, though. The Vite React template’s main.jsx renders <App /> inside <StrictMode>. The Next.js App Router enables StrictMode by default since Next.js 13.5.1. Pages Router apps have to opt in with reactStrictMode: true.

The cycle checks whether your cleanup actually works. If an effect misbehaves when React runs setup, cleanup, setup, it will also misbehave when a user navigates away and back. It will also misbehave when code edits re-run it in development: the Next.js Fast Refresh docs say effects should tolerate occasional re-running and note that StrictMode enforces this. For a deeper look at exactly when cleanup fires relative to paint, see useEffect vs useLayoutEffect.

How Do You Fix a useEffect That Runs Twice?

To fix a useEffect that runs twice, give it a cleanup function that reverses what the setup started. Each common failure has a specific fix:

What you see in devWhat’s actually wrongFix
Two fetches, possible stale dataNothing cancels the in-flight requestAbortController or an ignore flag in cleanup
A listener or subscription fires twiceIt is never removedRemove it in cleanup
A counter ends at 2The logic isn’t a side effectMove it to an event handler

A Fetch Firing Twice

A fetch that fires twice in useEffect has two fixes. The first is to abort the request in cleanup. When a fetch is aborted, it rejects with an AbortError DOMException, which you can safely ignore:

useEffect(() => {
  const controller = new AbortController();
  fetch(`/api/products/${id}`, { signal: controller.signal })
    .then((res) => res.json())
    .then((data) => setProduct(data))
    .catch((err) => {
      if (err.name !== 'AbortError') throw err;
    });
  return () => controller.abort();
}, [id]);

The other option is the ignore flag, which is the pattern React’s guide to synchronizing with effects uses for fetching:

useEffect(() => {
  let ignore = false;
  fetch(`/api/products/${id}`)
    .then((res) => res.json())
    .then((data) => {
      if (!ignore) setProduct(data);
    });
  return () => {
    ignore = true;
  };
}, [id]);

You will still see two requests in development after either fix. With AbortController, the first request is aborted. With ignore, both requests complete but only the second is allowed to set state. Production sends one request. The same cleanup also stops a slow response from overwriting a newer one when id changes.

A Listener That Leaks

An event listener added in useEffect leaks unless cleanup removes it. Declare the handler inside the effect so that cleanup removes the same function reference that setup added:

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

A Counter That Double-Increments

// Before: ends at 2 in development
useEffect(() => {
  setCount((c) => c + 1);
}, []);

Setup runs twice in development, so the increment runs twice. No cleanup can “un-increment” the counter, and that tells you the effect shouldn’t exist at all. Put the increment where the triggering event happens:

<button onClick={() => setCount((c) => c + 1)}>Add</button>

Should You Remove StrictMode or Add a useRef Guard?

No. Both approaches make the double run disappear and leave the underlying bug in place.

Turning off StrictMode, either by deleting the wrapper or setting reactStrictMode: false in your Next.js config, removes the test. The effects are still missing cleanup.

The useRef guard is the more tempting option:

// Avoid: hides missing cleanup
const hasRun = useRef(false);

useEffect(() => {
  if (hasRun.current) return;
  hasRun.current = true;
  subscribe();
}, []);

The guard skips the second setup in development, so the console looks clean. A useRef flag that skips the second run does not fix a missing cleanup. It hides the problem in development and leaves the leak in place for every real remount. A ref belongs to one component instance. When a route unmounts and mounts again in production, the new instance gets a new ref, so the effect runs again. There is still no cleanup, so the old subscription is never removed.

In a session replay of an app with missing effect cleanup, this bug can show up as duplicated requests or stale data after back/forward navigation. That is the same bug StrictMode surfaces in development.

When You Don’t Need an Effect

Many effects that fire twice should never have been effects. Effects are for synchronizing with something outside React because the component is on screen. You Might Not Need an Effect covers the two biggest groups of unnecessary effects.

Event-driven work belongs in event handlers. Sending a form, showing a toast after “Add to cart” or incrementing a counter happens because the user did something, not because a component rendered.

Values you can compute from props or state belong in render:

// Before: extra state, extra render, effect runs twice
const [fullName, setFullName] = useState('');
useEffect(() => {
  setFullName(first + ' ' + last);
}, [first, last]);

// After: computed during render
const fullName = first + ' ' + last;

A Quick Check: Is Your Effect Correct?

To check whether a useEffect is correct, log in both setup and cleanup:

useEffect(() => {
  console.log('setup');
  return () => console.log('cleanup');
}, []);

In development, the console should show:

setup
cleanup
setup

A production build logs setup once. If your logs show setup, cleanup, setup and the UI ends up correct, the effect is working as intended and there is nothing to fix.

Conclusion

A useEffect that runs twice in development is StrictMode checking whether your cleanup works. Don’t silence it. Go through each effect that fires twice and make it pass the check: abort or ignore stale requests, remove what you add, and move event-driven logic and derived values out of effects entirely. After your effects clean up correctly, add React error boundaries so that a component that throws while rendering doesn’t take down the whole page.

FAQs

Why does my useEffect run twice in production, where StrictMode is off?

In production, an effect runs again when one of its dependencies changes value or when the component remounts. If any dependency differs from the last render, the effect runs again, so objects and functions created during render count as new values every time. Changing the key prop, or conditional rendering that removes and re-adds the component, also remounts it and runs setup again.

Should I stop an analytics event from firing twice in development?

No. The React docs advise leaving a page-visit analytics call in its effect. Users can't tell whether it ran once or twice, and a production build sends each visit only once. Your development machine shouldn't be sending events into production metrics in the first place. If you need to debug the events, test on a staging build that runs in production mode, or switch StrictMode off for a short while.

Does useLayoutEffect also run twice under StrictMode?

Yes. The React reference for useLayoutEffect describes the same development behavior as useEffect: with StrictMode on, React does a setup and cleanup pass first, then the real setup. Layout effects need the same matching cleanup, such as disconnecting an observer or destroying a third-party widget instance. Switching to useLayoutEffect changes when the effect runs relative to paint, not how many times it runs in development.

Can I turn off StrictMode for one component and keep it for the rest of the app?

No. Once a tree is wrapped in StrictMode, every component inside it gets the checks, and no single component can opt out. What you can do is move the StrictMode wrapper further down so it covers only some parts of the app. If StrictMode doesn't wrap the root, React leaves out the extra effect run on first mount. That run would make child effects fire twice while their parents' effects fired once, which can't happen in production.

DevTools for the frontend

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.