Skip to content

Fix: React useEffect Runs Twice in Development

FixDevs ·

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.

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>
);

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.

For related React issues, see Fix: React useEffect Infinite Loop and Fix: React useEffect Missing Dependency.

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