Skip to content

Fix: React useEffect Runs Twice in Development

FixDevs · (Updated: )

Part of:  React & Frontend Errors

Quick Answer

Why React useEffect runs twice in development with Strict Mode, how to handle the double invocation correctly, when to add cleanup functions, and when the double-run actually reveals a real bug.

The Error

You add a useEffect and notice it runs twice on component mount in development:

useEffect(() => {
  console.log('Effect ran');    // Logs twice in development
  fetchData();                  // API called twice
}, []);

Or your code breaks because of the double execution:

useEffect(() => {
  const subscription = eventBus.subscribe('update', handleUpdate);
  // No cleanup — subscription added twice in dev, leaks in production
}, []);

In the React DevTools console you see the component mounting, unmounting, and mounting again.

Why This Happens

This is intentional behavior in React 18 + Strict Mode. In development only, React deliberately mounts every component twice:

  1. Component mounts → effects run
  2. Component unmounts → cleanup functions run
  3. Component mounts again → effects run again

This double-mount is a development tool to help you find bugs: if your effect doesn’t have a proper cleanup function, the double-mount will expose it. In production, effects only run once per mount.

React 18 introduced this behavior to prepare for an upcoming feature called “Offscreen Components” (fast navigation by preserving component state). Components will need to be mountable, unmountable, and remountable without breaking behavior. Strict Mode’s double-mount verifies your effects are ready for this.

The double-mount is not a bug, a regression, or a setting to disable. It is a contract: a component that does not survive a quick unmount-and-remount cycle is not safe for future React features like Suspense-driven navigation, transitions, or activity boundaries. The React team chose to surface that contract loudly during development rather than silently in production. Any code that crashes because it can’t tolerate a remount has the same crash waiting in production whenever the parent re-renders or routes change with state preservation.

Strict Mode is enabled by default in new React apps:

// main.tsx / index.tsx
import { StrictMode } from 'react';

createRoot(document.getElementById('root')).render(
  <StrictMode>     {/* ← This causes the double-mount */}
    <App />
  </StrictMode>
);

Version History That Changes the Failure Mode

The behavior of Strict Mode has changed substantially across React versions, and the “useEffect runs twice” problem only exists in some of them.

React 16.3 (March 2018). Strict Mode was introduced. It double-invoked constructor, render, and getDerivedStateFromProps to flag unsafe patterns, but it did not double-invoke effects. A project on React 16 will not see useEffect run twice.

React 17.0 (October 2020). Added the new JSX transform (no more import React from 'react' at the top of every file when using JSX). Strict Mode behavior was unchanged from 16 — still no effect double-invocation. Many projects that “upgraded to Strict Mode” in this era never noticed any double-effect surprise.

React 18.0 (March 2022). This is the cliff. Strict Mode now also double-invokes effects in development to simulate a mount → unmount → remount cycle. createRoot replaced ReactDOM.render, and migration guides quietly added <StrictMode> to the template, which is why so many React 18 projects suddenly see two API calls in dev. The behavior is documented as helping prepare for Offscreen / Activity (later renamed) features that preserve component state through hide/show transitions.

React 18.1 / 18.2 (mid-2022). Refined the strict-effects warning text. No semantic change.

React 18.3 (April 2024). A transitional release that surfaces deprecation warnings for APIs removed in 19. The double-invoke behavior is unchanged.

React 19.0 (April 2024 RC, December 2024 stable). Adds Actions, the use() hook, server-component improvements, and a new useActionState. Strict Mode keeps double-invoking effects with the same semantics as 18 — but the new APIs (use, Actions) interact with Strict Mode in ways that surface even more “this fires twice” complaints. Server-rendered components inside <StrictMode> still only mount once on the server; the double-invoke is exclusively a client-side dev behavior.

create-react-app deprecation (2025). The React team officially deprecated Create React App in early 2025 and recommends Vite, Next.js, or Remix for new projects. CRA templates included <StrictMode> by default since 16, so any project scaffolded with create-react-app and later bumped to React 18 inherits the double-invoke automatically without anyone touching index.tsx.

Vite + React + StrictMode interaction. Vite scaffolds main.tsx with <StrictMode> wrapping <App /> since Vite 2 and the @vitejs/plugin-react template. Vite’s HMR also re-runs effects on hot reload, layered on top of Strict Mode’s double-invoke, which produces three or four console messages per change. That is a Vite-plus-React behavior, not a separate React bug.

Next.js App Router. Strict Mode is on by default in next dev for app/ projects (Next 13.1+). Pages router projects inherited a reactStrictMode: true line in next.config.js from Next 11+. Disabling it via reactStrictMode: false hides the warning but also hides the bugs it catches.

If you see useEffect running twice and your package.json says "react": "^17", something else is fighting your effect — re-render loops, parent remounts, route changes — because React 17 Strict Mode does not double-invoke.

Fix 1: Add a Cleanup Function (The Right Solution)

The correct fix is to make your effect idempotent by adding a cleanup function. The double-mount is designed to verify that your effect + cleanup pair works correctly:

useEffect(() => {
  const subscription = eventBus.subscribe('update', handleUpdate);

  // Cleanup function — runs on unmount and before re-running the effect
  return () => {
    eventBus.unsubscribe('update', handleUpdate);
  };
}, []);

WebSocket connection:

useEffect(() => {
  const ws = new WebSocket('wss://api.example.com/stream');
  ws.onmessage = (event) => setData(JSON.parse(event.data));

  return () => {
    ws.close();
  };
}, []);

setTimeout / setInterval:

useEffect(() => {
  const timer = setInterval(() => {
    setCount(c => c + 1);
  }, 1000);

  return () => clearInterval(timer);
}, []);

Event listener:

useEffect(() => {
  const handleScroll = () => setScrollY(window.scrollY);
  window.addEventListener('scroll', handleScroll);

  return () => window.removeEventListener('scroll', handleScroll);
}, []);

AbortController for fetch:

useEffect(() => {
  const controller = new AbortController();

  fetch('/api/data', { signal: controller.signal })
    .then(res => res.json())
    .then(data => setData(data))
    .catch(err => {
      if (err.name !== 'AbortError') {
        setError(err.message);
      }
    });

  return () => controller.abort();  // Cancel the request on unmount
}, []);

With a proper cleanup, the sequence becomes: mount → effect → unmount → cleanup → mount → effect → working correctly.

Fix 2: Fix One-Time Operations (Analytics, Logging)

Some operations genuinely should only run once even in development. For these, use a ref as a guard:

const hasTracked = useRef(false);

useEffect(() => {
  if (hasTracked.current) return;
  hasTracked.current = true;

  // Only runs once, even in Strict Mode
  analytics.track('page_view', { page: '/dashboard' });
}, []);

Use this sparingly. If you find yourself adding hasTracked guards to many effects, consider whether those effects truly need to be in useEffect at all. Analytics calls and one-time initialization often belong in a module-level singleton rather than a component effect.

Alternative: move truly one-time code outside the component:

// Module-level — runs once when the module is first loaded
analytics.initialize({ apiKey: import.meta.env.VITE_ANALYTICS_KEY });

function App() {
  // No useEffect needed for initialization
  return <Router />;
}

Fix 3: Handle API Calls Correctly

Double-fetching in development is the most common symptom. The cleanest approach uses AbortController:

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    const controller = new AbortController();
    let cancelled = false;

    async function fetchUser() {
      try {
        const res = await fetch(`/api/users/${userId}`, {
          signal: controller.signal,
        });
        const data = await res.json();
        if (!cancelled) setUser(data);
      } catch (err) {
        if (err.name !== 'AbortError') {
          console.error(err);
        }
      }
    }

    fetchUser();

    return () => {
      cancelled = true;
      controller.abort();
    };
  }, [userId]);

  return user ? <div>{user.name}</div> : <div>Loading...</div>;
}

In Strict Mode, the first fetch is aborted by the cleanup before the second mount triggers a new fetch. In production, there’s only one fetch — no extra request.

Use a data-fetching library to avoid writing this boilerplate. React Query, SWR, and TanStack Query all handle deduplication, caching, and cancellation correctly:

// React Query — handles deduplication automatically
import { useQuery } from '@tanstack/react-query';

function UserProfile({ userId }) {
  const { data: user } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json()),
  });

  return user ? <div>{user.name}</div> : <div>Loading...</div>;
}

Fix 4: Fix Third-Party Library Initialization

Some libraries (maps, charts, WYSIWYG editors) break when initialized twice because they attach to a DOM node that already has the library attached:

// Broken — initializing twice causes errors
useEffect(() => {
  const map = new mapboxgl.Map({ container: mapRef.current });
}, []);

// Fixed — clean up the previous instance
useEffect(() => {
  const map = new mapboxgl.Map({
    container: mapRef.current,
    style: 'mapbox://styles/mapbox/streets-v12',
  });

  return () => map.remove();  // mapboxgl's cleanup method
}, []);

If the library doesn’t provide a cleanup method, check the docs for a destroy(), dispose(), or unmount() method.

Use a ref to guard against re-initialization:

const mapInstanceRef = useRef(null);

useEffect(() => {
  if (mapInstanceRef.current) return;  // Already initialized

  mapInstanceRef.current = new MapLibrary({ container: mapRef.current });

  return () => {
    mapInstanceRef.current?.destroy();
    mapInstanceRef.current = null;
  };
}, []);

Fix 5: Don’t Disable Strict Mode (But Here’s How If You Must)

The temptation is to remove <StrictMode> to stop the double-mounting. Avoid this — you’ll lose the development warnings that help catch bugs before they reach production.

If you must disable it temporarily for debugging:

// main.tsx — temporarily remove StrictMode
createRoot(document.getElementById('root')).render(
  // <StrictMode>
    <App />
  // </StrictMode>
);

A better approach: use the double-mount as a signal. If removing <StrictMode> fixes the bug, the bug is real and your effect needs a cleanup function. Find and fix the underlying issue instead.

Understanding What the Double-Mount Catches

The double-mount is revealing real bugs. Examples of bugs it exposes:

Connection count grows unboundedly:

// Bug — subscribes twice, never unsubscribes
useEffect(() => {
  socket.on('message', handleMessage);  // Added twice in dev
}, []);

// Fix
useEffect(() => {
  socket.on('message', handleMessage);
  return () => socket.off('message', handleMessage);
}, []);

DOM mutation applied twice:

// Bug — adds class twice
useEffect(() => {
  document.body.classList.add('modal-open');
}, []);

// Fix
useEffect(() => {
  document.body.classList.add('modal-open');
  return () => document.body.classList.remove('modal-open');
}, []);

Global state corrupted:

// Bug — increments a global counter twice
useEffect(() => {
  globalStore.activeComponents++;
}, []);

// Fix
useEffect(() => {
  globalStore.activeComponents++;
  return () => { globalStore.activeComponents--; };
}, []);

In each case, the double-mount in development is showing you exactly what will go wrong if the component is ever remounted in production (navigation away and back, React Suspense, concurrent features).

Still Not Working?

Verify it only runs twice in development. Build your app (npm run build && npm run preview) and confirm the effect runs once. If it still runs twice in production, you have a separate bug (the component is genuinely mounting twice in your component tree).

Check React version. The double-mount behavior in Strict Mode was introduced in React 18. In React 17 and earlier, Strict Mode doesn’t double-mount.

npm list react
# [email protected] → double-mount in Strict Mode
# [email protected] → no double-mount

Check for effects inside loops or conditional rendering — if a parent component re-renders and your component is conditionally rendered, real double-mounts can occur outside of Strict Mode.

Check whether the parent key prop is changing. When a parent re-renders and passes a new key to your component, React unmounts and remounts it. That looks identical to Strict Mode’s double-mount but happens in production too. Stable keys (the user’s ID, not an array index) prevent it.

Check React Server Components and Suspense boundaries. In React 19 with app/ router or React Server Components, the server renders the component once on the server and the client hydrates once. If hydration mismatches, React falls back to a fresh client mount, producing what looks like a duplicate effect run. Resolve the hydration warning first; the effect “double-run” will disappear.

Check Vite HMR with <StrictMode>. Hot module reload re-runs the module which causes the component tree to re-mount under Strict Mode. You see effect → cleanup → effect → cleanup → effect on every save. This is expected. If you want to confirm production behavior, run npm run build && npm run preview instead of the dev server.

For related React issues, see Fix: React useEffect Infinite Loop, Fix: React useEffect Missing Dependency, Fix: React Strict Mode Double Render, and Fix: React useState Not Updating.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles