Fix: React StrictMode Double Render — Side Effects Running Twice in Development
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())) useReducerreducer functionsuseMemocallbacksuseEffectsetup 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
useEffectcleanup 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 completesExternal 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 timeFix 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
pageNamechanges, the ref prevents tracking the new page. Make sure to handlepageNamechanges 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 analyticsFix 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 StrictModeAdd 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 effectFix 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.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: AutoAnimate Not Working — Transitions Not Playing, List Items Not Animating, or React State Changes Ignored
How to fix @formkit/auto-animate issues — parent ref setup, React useAutoAnimate hook, Vue directive, animation customization, disabling for specific elements, and framework integration.
Fix: Blurhash Not Working — Placeholder Not Rendering, Encoding Failing, or Colors Wrong
How to fix Blurhash image placeholder issues — encoding with Sharp, decoding in React, canvas rendering, Next.js image placeholders, CSS blur fallback, and performance optimization.
Fix: Embla Carousel Not Working — Slides Not Scrolling, Autoplay Not Starting, or Thumbnails Not Syncing
How to fix Embla Carousel issues — React setup, slide sizing, autoplay and navigation plugins, loop mode, thumbnail carousels, responsive breakpoints, and vertical scrolling.
Fix: i18next Not Working — Translations Missing, Language Not Switching, or Namespace Errors
How to fix i18next issues — react-i18next setup, translation file loading, namespace configuration, language detection, interpolation, pluralization, and Next.js integration.