Skip to content

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

FixDevs ·

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.

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

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)

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.

For related React issues, see Fix: React useEffect Infinite Loop and Fix: React Hydration Error.

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