React useEffect Firing Twice in Dev but Once in Production: What's Going On
You just added a useEffect that fetches data or logs an event, and you notice it fires twice every time your component mounts β but only in development. You check production and it runs once, as expected. It feels like a bug in React itself, and it's one of the most common sources of confusion for developers upgrading to React 18.
It isn't a bug. It's intentional, and once you understand why, you'll write better effects because of it.
What You'll Learn
- Why React's Strict Mode causes
useEffectto run twice in development - The exact mount/unmount/remount cycle React performs and what it's checking for
- How to write cleanup functions that make your effects resilient
- Practical fixes for the most common patterns: fetch calls, subscriptions, timers, and analytics
- What to avoid when working around this behavior
Why Your useEffect Is Running Twice
The short answer: you're running your app wrapped in <React.StrictMode>, which is the default when you create a new app with Vite or Create React App. Strict Mode opts into a set of extra development checks, and in React 18, one of those checks deliberately mounts, unmounts, and then remounts every component to surface problems with your effects.
This only happens in development. When you build for production, Strict Mode checks are stripped out entirely, and components mount exactly once.
What Strict Mode Actually Does
React's Strict Mode has always existed, but React 18 added a new behavior called intentional remounting. Before React 18, Strict Mode double-invoked render functions and lifecycle methods to detect side effects, but it didn't actually unmount and remount components.
In React 18, it goes further. When a component mounts, React will:
- Mount the component and run all effects.
- Immediately unmount the component and run all cleanup functions.
- Remount the component and run all effects again.
From your component's perspective, it looks like a duplicate fire. From React's perspective, it's simulating what happens when a user navigates away and comes back β a pattern that will become more common as React rolls out features like concurrent rendering and offscreen components.
The Mount β Unmount β Remount Cycle
Here's a minimal example to make this concrete:
import { useEffect } from 'react';
function MyComponent() {
useEffect(() => {
console.log('effect ran');
return () => {
console.log('cleanup ran');
};
}, []);
return <div>Hello</div>;
}In development with Strict Mode, your console will show:
effect ran
cleanup ran
effect ranIn production, you'll only see:
effect ranThe component goes through the full mount/unmount/remount sequence in development. If your effect opens a WebSocket connection on mount and closes it on cleanup, it will open, close, and open again β which can look broken if you're not expecting it.
Why This Behavior Is a Feature, Not a Bug
React's team added this specifically to catch effects that aren't properly cleaned up. If your effect runs twice and your app breaks, that's React telling you something important: your effect has a problem that will bite you in real usage, not just in testing.
Consider a subscription effect that doesn't clean up after itself:
// Problematic: no cleanup
useEffect(() => {
const subscription = someEventBus.subscribe(handleEvent);
// No return value β cleanup never runs
}, []);
Without Strict Mode's remount, you'd never notice the leak during development. With it, you'll see the handler getting registered twice and firing twice for every event. That's the warning sign React wants you to catch early.
React's future features β particularly offscreen rendering, where components can be hidden and reshown without full teardown β depend on effects being idempotent. Getting into the habit now pays off later.
How to Write Effects That Survive the Double Fire
The core principle is simple: every effect that creates something should return a cleanup function that destroys it. When the effect runs a second time, it starts from a clean slate.
Think about what your effect does and what the inverse operation is:
| Effect action | Cleanup action |
|---|---|
| Open a WebSocket | Close the WebSocket |
| Subscribe to an event | Unsubscribe |
| Start a timer | Clear the timer |
| Fetch data | Abort the request |
| Add an event listener | Remove the event listener |
If you build each effect with its paired cleanup, the double-fire behavior becomes invisible β the second run is indistinguishable from the first.
Cleanup Functions: Your Main Tool Here
The cleanup function returned from useEffect is how you tell React what to do when the component unmounts or before the effect re-runs. Here are the patterns you'll use most often.
Aborting fetch requests
Use AbortController to cancel in-flight requests when the component unmounts. This also prevents the classic "can't perform a state update on an unmounted component" warning.
useEffect(() => {
const controller = new AbortController();
async function fetchData() {
try {
const res = await fetch('/api/data', { signal: controller.signal });
const json = await res.json();
setData(json);
} catch (err) {
if (err.name !== 'AbortError') {
console.error('Fetch failed:', err);
}
}
}
fetchData();
return () => {
controller.abort();
};
}, []);When Strict Mode runs cleanup, the first request gets aborted. The second mount fires a fresh request. In production, the single request completes normally. Either way, your data ends up in the right place. For more on requests that don't complete cleanly, see what happens when fetch requests hang indefinitely.
Clearing timers
useEffect(() => {
const timerId = setTimeout(() => {
doSomething();
}, 1000);
return () => {
clearTimeout(timerId);
};
}, []);Removing event listeners
useEffect(() => {
function handleResize() {
setWidth(window.innerWidth);
}
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, []);Closing connections
useEffect(() => {
const socket = new WebSocket('wss://example.com/ws');
socket.addEventListener('message', handleMessage);
return () => {
socket.close();
};
}, []);Common Patterns and How to Fix Them
Analytics and logging
This is where developers most often reach for a workaround. You want to fire a "page viewed" event exactly once, but Strict Mode fires it twice. A ref-based guard is a reasonable approach if you understand the trade-off:
import { useEffect, useRef } from 'react';
function PageView() {
const didLogRef = useRef(false);
useEffect(() => {
if (!didLogRef.current) {
didLogRef.current = true;
trackPageView();
}
}, []);
return null;
}The ref persists across the remount, so the event fires once even in development. The trade-off is you lose the Strict Mode signal β if trackPageView has a bug, you won't see it in development. Use this pattern deliberately, not as a default escape hatch.
A better long-term approach is to move one-shot side effects out of useEffect entirely. React's own documentation recommends initializing analytics at the app root level, outside any component, so it genuinely runs once.
Initializing a third-party library
Some libraries (charting libraries, map SDKs, rich text editors) expect to be initialized once and mutate a DOM node. If they can't handle being initialized twice, you need to track the instance:
import { useEffect, useRef } from 'react';
function MapComponent() {
const containerRef = useRef(null);
const mapRef = useRef(null);
useEffect(() => {
if (mapRef.current) return; // Already initialized
mapRef.current = new ThirdPartyMap(containerRef.current, {
center: [0, 0],
zoom: 2,
});
return () => {
mapRef.current?.destroy();
mapRef.current = null;
};
}, []);
return <div ref={containerRef} style={{ height: '400px' }} />;
}The cleanup sets the ref back to null, so the second mount re-initializes cleanly.
Common Pitfalls to Avoid
Don't turn off Strict Mode to fix the double-fire. Removing <React.StrictMode> from your app entry point will make the symptom disappear, but you'll lose the development checks that protect you from real bugs. The double-fire is the check working correctly.
Don't use a module-level variable as a guard. A variable declared outside the component (e.g., let initialized = false) persists across hot-module reloads in dev tools, which can hide issues and cause confusing behavior between sessions.
Don't assume your effect always runs before cleanup matters. In concurrent mode, React can pause rendering. Your cleanup must be safe to call even if the effect ran only partially.
Don't ignore the cleanup return value. If you write an async function directly as the useEffect callback, it returns a Promise, not a cleanup function. React will silently ignore it:
// Wrong: async functions return Promises, not cleanup functions
useEffect(async () => {
const data = await fetchData();
setData(data);
// No way to return a cleanup function from here
}, []);
// Right: define async inside, return cleanup from the outer function
useEffect(() => {
const controller = new AbortController();
fetchData(controller.signal).then(setData);
return () => controller.abort();
}, []);This is a subtle but important distinction. The async-as-callback pattern means you can never return a cleanup function, and any unhandled rejection from the async call won't be caught cleanly either.
Wrapping Up
The double-fire behavior in development is React giving you a safety net. If your effects break when they run twice, they'll also break in real-world scenarios like navigation, concurrent rendering, and offscreen components. The fix isn't to suppress the behavior β it's to write effects that are resilient to it.
Here's what to do next:
- Audit your existing
useEffecthooks and verify every one that opens a connection, sets a timer, or subscribes to an event has a matching cleanup function. - Replace any bare async callbacks in
useEffectwith the inner-async pattern and anAbortControllerfor fetch calls. - Move true one-shot initialization (analytics setup, global SDK init) outside of component effects and into module-level code or your app's entry point.
- Keep Strict Mode enabled. Treat any double-fire weirdness as a diagnostic, not an annoyance.
- If you use third-party libraries that mutate the DOM, store the instance in a
useRefand guard against re-initialization in the effect body while still destroying in cleanup.
Once your effects are written this way, the development/production difference becomes a non-issue. Your code will be cleaner and more robust regardless of how many times React decides to run it.
Frequently Asked Questions
Why does useEffect run twice even with an empty dependency array in React?
In React 18 with Strict Mode enabled, components are intentionally mounted, unmounted, and remounted in development to help detect side effects that aren't properly cleaned up. This means useEffect runs twice even with an empty dependency array in development, but exactly once in production builds.
How do I make useEffect fire only once in React development?
The recommended approach is not to suppress the double-fire but to write a proper cleanup function so the second run is harmless. If you have a legitimate one-shot operation like analytics, you can use a useRef flag to guard it, but avoid disabling Strict Mode entirely as that removes valuable development checks.
Does turning off Strict Mode stop useEffect from running twice?
Yes, removing React.StrictMode from your app will stop the double-fire behavior, but this is not recommended. Strict Mode's intentional remounting helps you catch bugs with unclean effects before they reach production, so turning it off hides problems rather than fixing them.
How should I handle fetch requests inside useEffect to avoid issues with the double-fire?
Use an AbortController inside your useEffect and return a cleanup function that calls controller.abort(). When Strict Mode triggers cleanup between the two mounts, the first request gets cancelled and the second mount fires a fresh request, so your component ends up with correct data without duplicate calls or state updates on unmounted components.
Is the useEffect double-fire behavior in development a bug that React will fix?
No, it is an intentional feature introduced in React 18. React's team added it to prepare developers for upcoming features like offscreen rendering, where components may be hidden and reshown without a full remount. Writing effects that handle the double-fire correctly makes your code ready for these future capabilities.
π€ Share this article
Sign in to saveRelated Articles
Comments (0)
No comments yet. Be the first!