Skip to content

Fix: React StrictMode Double Render — Side Effects Running Twice in Development

FixDevs · (Updated: )

Part of:  React & Frontend Errors

Quick Answer

How to fix React StrictMode double render issues — understanding intentional double invocation, fixing side effects, useEffect cleanup, external subscriptions, and production behavior.

The Problem

In development, a React component renders twice even though it’s called once:

function UserList() {
  console.log('Rendering UserList');
  // Logs appear twice: "Rendering UserList" "Rendering UserList"

  const [users, setUsers] = useState([]);

  useEffect(() => {
    console.log('Fetching users...');
    fetchUsers().then(setUsers);
    // "Fetching users..." appears twice — two API calls are made
  }, []);

  return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}

Or a component creates duplicate entries in a database or external system:

useEffect(() => {
  trackPageView('/dashboard');   // Analytics event fires twice in development
  registerDevice(deviceId);      // Device registered twice — duplicates in backend
}, []);

Or a subscription is set up twice, causing duplicate events:

useEffect(() => {
  const ws = new WebSocket('wss://api.example.com/feed');
  ws.onmessage = (event) => {
    setMessages(prev => [...prev, event.data]);
  };
  // Two WebSocket connections — each message appears twice
}, []);

Why This Happens

React’s <StrictMode> intentionally invokes certain functions twice in development only. This behavior was introduced in React 18 and expanded in later versions. The purpose is to surface bugs early by simulating the component lifecycle that React’s concurrent features use internally.

What StrictMode double-invokes:

  • Component render functions (the function body itself)
  • State initializer functions (useState(() => computeInitialState()))
  • useReducer reducer functions
  • useMemo callbacks
  • useEffect setup AND cleanup functions — the effect runs, cleanup runs, then the effect runs again

Why React does this:

  • Detect side effects in render functions (renders must be pure)
  • Verify that useEffect cleanup functions properly undo setup
  • Expose bugs where effects don’t clean up after themselves

The double-invocation cycle for useEffect follows a specific sequence: setup runs, cleanup runs immediately, then setup runs again. React is simulating a component being unmounted and remounted. This catches a wide class of bugs: leaked event listeners, unclosed connections, stale timers, and resources that are acquired but never released. If your effect’s cleanup correctly undoes the setup, the double-fire is invisible to the user. If it does not, the bug was already there — StrictMode just made it visible.

Key facts:

  • This ONLY happens in development mode with <StrictMode> enabled
  • Production builds never double-invoke
  • The second render is discarded — React uses the first render’s result
  • For useEffect, the sequence is: setup -> cleanup -> setup (React simulates unmount/remount)

Platform and Environment Differences

StrictMode’s double-render behavior interacts differently with each React framework and build tool, which causes confusion when the same code behaves differently across environments.

Next.js (App Router and Pages Router). Next.js wraps pages in <StrictMode> by default starting from Next.js 13.4. In the App Router, Server Components do not have StrictMode behavior because they run on the server where StrictMode is not active. Only Client Components (files with "use client") experience the double-render. In the Pages Router, all components are client-rendered and experience the double-render. You can disable StrictMode in next.config.js with reactStrictMode: false, but this is not recommended. A common mistake is seeing double renders in Next.js development and assuming it is a Next.js bug rather than React’s intentional behavior.

Vite with React plugin. The Vite React plugin (@vitejs/plugin-react) does not add <StrictMode> — it only appears if your main.tsx wraps <App /> in it. Vite’s HMR (Hot Module Replacement) also causes effects to re-run when a module is updated, which can look like a third render. The key difference: HMR re-runs happen when you edit code, while StrictMode double-renders happen on every mount. If effects fire three times during development, the first two are StrictMode and the third is HMR.

Create React App (CRA). CRA’s template includes <StrictMode> by default in src/index.tsx. Since CRA uses webpack, its HMR behavior differs from Vite’s. Webpack’s fast refresh preserves component state across edits, so effects do not re-run on code changes unless the component’s hooks change.

React Native. React Native does not enable StrictMode by default and does not support the double-invoke behavior for effects. The <StrictMode> wrapper is available but only double-invokes render functions, not effects. This means a component that works in React Native development may break when ported to a React web app with StrictMode because the missing cleanup was never caught.

Preact with compat layer. Preact’s preact/compat module provides React API compatibility, but Preact does not implement StrictMode’s double-render behavior. Effects run once. Code developed with Preact may rely on effects running once and then fail when migrated to React 18+.

Production builds. No framework or build tool enables StrictMode behavior in production builds. process.env.NODE_ENV === 'production' disables all double-invocations regardless of whether <StrictMode> is in the component tree. If you see double renders in production, the cause is not StrictMode — check for parent component re-renders, state changes during render, or duplicate component mounts.

Fix 1: Make Effects Idempotent

The correct fix for most cases — design effects so running them twice has the same result as running once:

// PROBLEM — fetch called twice, race condition possible
useEffect(() => {
  fetch('/api/users').then(r => r.json()).then(setUsers);
}, []);

// FIX — use an AbortController for cleanup
useEffect(() => {
  const controller = new AbortController();

  fetch('/api/users', { signal: controller.signal })
    .then(r => r.json())
    .then(setUsers)
    .catch(err => {
      if (err.name !== 'AbortError') {
        setError(err);
      }
    });

  // Cleanup: abort the first fetch before the second runs
  return () => controller.abort();
}, []);
// StrictMode sequence:
// 1. First render: fetch starts
// 2. Cleanup: first fetch aborted (AbortError caught and ignored)
// 3. Second render: fresh fetch starts — this is the one that completes

External subscription cleanup:

// PROBLEM — duplicate subscription
useEffect(() => {
  const ws = new WebSocket('wss://example.com/feed');
  ws.onmessage = (event) => setMessages(prev => [...prev, event.data]);
  // No cleanup — StrictMode creates TWO WebSocket connections
}, []);

// FIX — close WebSocket in cleanup
useEffect(() => {
  const ws = new WebSocket('wss://example.com/feed');
  ws.onmessage = (event) => setMessages(prev => [...prev, event.data]);

  return () => {
    ws.close();   // StrictMode closes first connection before opening second
  };
}, []);
// Result: only one active WebSocket at a time

Fix 2: Fix useEffect with External Systems

Pattern for connecting to external systems:

// EventEmitter subscription
useEffect(() => {
  function handleUpdate(data) {
    setData(data);
  }

  emitter.on('update', handleUpdate);

  // Cleanup removes listener — prevents duplicate listeners in StrictMode
  return () => {
    emitter.off('update', handleUpdate);
  };
}, []);

// Redux / Zustand store subscription
useEffect(() => {
  const unsubscribe = store.subscribe(() => {
    setState(store.getState());
  });

  return unsubscribe;   // Cleanup: unsubscribe
}, []);

// Third-party library initialization
useEffect(() => {
  const chart = new Chart(canvasRef.current, config);

  return () => {
    chart.destroy();   // Cleanup: destroy to allow clean re-initialization
  };
}, []);

Fix 3: Handle One-Time Effects Correctly

Some operations genuinely should only run once (analytics, initializers). Use a ref to track if the effect already ran:

// For analytics and other truly-once operations
import { useEffect, useRef } from 'react';

function AnalyticsPage({ pageName }) {
  const tracked = useRef(false);

  useEffect(() => {
    if (tracked.current) return;   // Skip second invocation
    tracked.current = true;

    analytics.trackPageView(pageName);   // Fires only once
  }, [pageName]);

  return <div>...</div>;
}

Note: This pattern works but consider whether the operation truly can’t tolerate cleanup/re-run. If pageName changes, the ref prevents tracking the new page. Make sure to handle pageName changes correctly:

useEffect(() => {
  analytics.trackPageView(pageName);   // Fire each time pageName changes — is idempotent
  // No cleanup needed — tracking a page view doesn't need to be undone
}, [pageName]);
// This is actually fine without the ref — tracking fires once per pageName change
// The StrictMode double-fire on mount is usually acceptable for analytics

Fix 4: Fix State Initialization Side Effects

State initializers run twice in StrictMode. Keep them pure:

// WRONG — side effect in state initializer
const [connection, setConnection] = useState(() => {
  const ws = new WebSocket('wss://example.com');   // Opens TWO connections in StrictMode
  return ws;
});

// WRONG — expensive side effect in state initializer
const [data, setData] = useState(() => {
  fetchData();   // Called twice in StrictMode
  return null;
});

// CORRECT — pure computation only in state initializer
const [items, setItems] = useState(() => {
  return JSON.parse(localStorage.getItem('items') || '[]');  // Pure read — fine
});

// CORRECT — side effects go in useEffect, not useState
const [connection, setConnection] = useState(null);

useEffect(() => {
  const ws = new WebSocket('wss://example.com');
  setConnection(ws);

  return () => ws.close();   // Proper cleanup
}, []);

Fix 5: React Query and SWR — No Double Fetch Problem

Libraries like React Query and SWR handle deduplication automatically — they don’t fire duplicate network requests even with StrictMode:

import { useQuery } from '@tanstack/react-query';

function UserList() {
  // Only ONE network request made, even in StrictMode
  // React Query deduplicates requests and caches results
  const { data: users, isLoading, error } = useQuery({
    queryKey: ['users'],
    queryFn: () => fetch('/api/users').then(r => r.json()),
  });

  if (isLoading) return <p>Loading...</p>;
  if (error) return <p>Error: {error.message}</p>;

  return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}

Using SWR:

import useSWR from 'swr';

const fetcher = (url) => fetch(url).then(r => r.json());

function UserList() {
  // SWR also deduplicates — single request in StrictMode
  const { data: users, error } = useSWR('/api/users', fetcher);

  // ...
}

Fix 6: Check if StrictMode Is Causing the Issue

Verify whether StrictMode is the cause before applying fixes:

// Temporarily disable StrictMode to confirm it's the cause
// (Don't leave this disabled in production code)

// Before:
root.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

// After (for debugging only):
root.render(<App />);

// If the double-render stops, StrictMode was the cause
// Fix the underlying issue instead of removing StrictMode

Add logging to understand the render cycle:

function UserList() {
  const renderCount = useRef(0);
  renderCount.current += 1;
  console.log(`Render #${renderCount.current}`);

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

  return <div>...</div>;
}

// In StrictMode development, output is:
// Render #1
// Render #2          ← Discarded — React uses render #1's output
// Effect setup       ← First setup
// Effect cleanup     ← StrictMode cleanup
// Effect setup       ← Second setup — this is the active effect

Fix 7: Production Behavior vs Development Behavior

Understanding the difference helps set correct expectations:

// In PRODUCTION:
// - Component renders once
// - useEffect fires once
// - No cleanup/re-run cycle

// In DEVELOPMENT with StrictMode:
// - Component function called twice (second result discarded)
// - useEffect: setup → cleanup → setup
// - useState initializer called twice (second result discarded)

// Code that works correctly in StrictMode will work correctly in production
// Code that only "works" by removing StrictMode has a latent bug

// EXAMPLE — bug revealed by StrictMode
useEffect(() => {
  const handler = () => setCount(prev => prev + 1);
  window.addEventListener('resize', handler);
  // MISSING CLEANUP — in production, one listener added (works)
  // In StrictMode, two listeners added (count increments twice per resize)
  // Fix: return () => window.removeEventListener('resize', handler);
}, []);

Still Not Working?

useInsertionEffect — runs synchronously before DOM mutations. Only used for CSS-in-JS libraries. Does NOT double-fire in StrictMode.

useLayoutEffect — runs synchronously after DOM mutations, before the browser paints. Does double-fire in StrictMode. Must also have proper cleanup.

Third-party libraries not StrictMode-compatible — some older libraries weren’t designed with StrictMode in mind. They may not support being mounted/unmounted/remounted. Check the library’s documentation or issues for <StrictMode> compatibility.

React 18 vs React 17 StrictMode — React 18 added the unmount/remount behavior for useEffect. In React 17, StrictMode only double-invoked the render function, not effects. If you recently upgraded to React 18, new StrictMode behavior may surface previously hidden bugs.

console.log appears suppressed in StrictMode — React 18 intentionally suppresses console.log during the second render in development to reduce noise. If you are debugging and see only one log, open the browser DevTools settings and check “Preserve log” or look for a React DevTools option to show all logs. Chrome 120+ and Firefox 121+ respect this suppression; older browsers do not.

Zustand or Jotai state appearing stale after double-render — external state managers that do not use React’s state primitives internally may return stale values after the StrictMode cleanup/re-setup cycle. Upgrade to the latest version of the library, which typically includes StrictMode compatibility fixes. For Zustand, version 4.4+ handles this correctly.

For related React issues, see Fix: React useEffect Infinite Loop, Fix: React Hydration Error, Fix: React useEffect Runs Twice, 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