You've just created a fresh React project. You write a simple useEffect to fetch some data. You check the console—and there it is: two network requests instead of one. Two console logs. Two of everything.
Your first instinct is probably to panic. Is this a bug? Did you set up something wrong? Should you downgrade React? Search Stack Overflow?
Take a breath. Your code isn't broken. This behavior is intentional, and understanding why it exists will make you a significantly better React developer. More importantly, learning to write effects that work correctly under this behavior will prevent real bugs in production that would otherwise slip past your development testing.
This guide will take you from confusion to mastery. We'll explore exactly what's happening, why the React team made this seemingly frustrating decision, and—most importantly—how to write effects that are resilient, correct, and production-ready.
The Symptom: Double Invocations in Development
Let's start with what you're actually seeing. Consider this component:
import { useEffect, useState } from 'react';
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
console.log('Effect running for user:', userId);
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => setUser(data));
}, [userId]);
return <div>{user?.name}</div>;
}
In development mode (with StrictMode enabled), you'll see:
Effect running for user: 123
Effect running for user: 123
And in your Network tab: two identical requests to /api/users/123.
If you've been writing React for a while, this might be new behavior. Prior to React 18, effects ran once in development—just like in production. So what changed?
The Root Cause: React.StrictMode's Double-Invocation
The culprit is React.StrictMode, which wraps your application (usually in index.js or main.jsx):
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>
);
Starting with React 18, and continuing in React 19, Strict Mode deliberately:
- Mounts your component
- Unmounts it immediately
- Remounts it
This happens only in development. In production builds, your effect runs exactly once per mount (unless dependencies change).
Here's the kicker: this intentional double-invocation is designed to expose bugs in your code.
Why Would React Do This? Understanding the Motivation
The React team didn't add this behavior to annoy developers. They added it because of a fundamental truth about effects:
If your effect breaks when it runs twice, it would have broken in production anyway—you just wouldn't have caught it during development.
Think about real-world scenarios where effects can run multiple times:
Scenario 1: Fast Refresh During Development
You're coding, you save a file, and Fast Refresh re-renders your component. If your effect sets up a subscription but doesn't clean it up properly, you now have duplicate subscriptions.
Scenario 2: React Suspense and Transitions
With React 18+ features like startTransition and Suspense, React may need to "suspend" a render, then retry it. Components can mount, unmount, and remount as part of normal concurrent rendering behavior.
Scenario 3: Component Remounting in Real Navigation
User navigates away, back button, component remounts. If your effect creates a WebSocket connection without cleanup, you now have orphaned connections.
Scenario 4: Future Features
The React team is building towards "Offscreen" APIs (rendering components in the background before they're visible). These will require components to mount/unmount multiple times as part of their normal lifecycle.
The double-invocation in Strict Mode simulates these real conditions. If your code survives the Strict Mode stress test, it's much more likely to work correctly in all these scenarios.
The Real Problem: Effects Without Cleanup
Let's diagnose where most developers go wrong. Look at this effect:
useEffect(() => {
const socket = new WebSocket('wss://api.example.com/realtime');
socket.onmessage = (event) => {
setMessages(prev => [...prev, event.data]);
};
}, []);
What's wrong? There's no cleanup function. When Strict Mode unmounts and remounts, you create a second WebSocket connection. The first one is now orphaned—still receiving messages, still consuming memory, but with no reference to close it.
The correct pattern:
useEffect(() => {
const socket = new WebSocket('wss://api.example.com/realtime');
socket.onmessage = (event) => {
setMessages(prev => [...prev, event.data]);
};
// Cleanup function - called on unmount
return () => {
socket.close();
};
}, []);
Now the flow is:
- Mount → Create WebSocket #1
- Strict Mode unmount → Close WebSocket #1
- Remount → Create WebSocket #2
Only one connection exists at a time. Your effect is resilient.
Fixing Common Effect Patterns
Let's walk through the most common effect patterns and how to make them Strict Mode-proof.
Pattern 1: Data Fetching with Abort Controller
The naive approach to fetching data:
// ❌ Problematic: Double fetch, potential race conditions
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(setUser);
}, [userId]);
The resilient approach:
// ✅ Correct: Aborts in-flight requests on cleanup
useEffect(() => {
const controller = new AbortController();
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then(res => res.json())
.then(setUser)
.catch(err => {
if (err.name !== 'AbortError') {
console.error('Fetch failed:', err);
}
});
return () => controller.abort();
}, [userId]);
What this achieves:
- When Strict Mode unmounts, the cleanup aborts the first request
- The second request proceeds normally
- In production with fast navigation, old requests don't overwrite new data (the race condition problem)
Pattern 2: Event Listeners on Window/Document
// ❌ Problematic: Multiple listeners accumulate
useEffect(() => {
window.addEventListener('resize', handleResize);
}, []);
// ✅ Correct: Clean removal
useEffect(() => {
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
Pattern 3: Timers and Intervals
// ❌ Problematic: Multiple intervals running
useEffect(() => {
setInterval(() => {
setCount(c => c + 1);
}, 1000);
}, []);
// ✅ Correct: Clear the interval
useEffect(() => {
const id = setInterval(() => {
setCount(c => c + 1);
}, 1000);
return () => clearInterval(id);
}, []);
Pattern 4: Third-Party Library Initialization
Many charting libraries, animation frameworks, or DOM manipulation tools need initialization and destruction:
// ❌ Problematic: Chart initializes twice
useEffect(() => {
const chart = new ChartLibrary(containerRef.current, {
data: chartData,
});
}, [chartData]);
// ✅ Correct: Destroy on cleanup
useEffect(() => {
const chart = new ChartLibrary(containerRef.current, {
data: chartData,
});
return () => chart.destroy();
}, [chartData]);
Pattern 5: Analytics and Tracking
This is where it gets nuanced. You probably don't want your analytics to de-duplicate:
// This will fire twice in Strict Mode - is that a problem?
useEffect(() => {
analytics.track('page_viewed', { page: pathname });
}, [pathname]);
The answer: It depends on your analytics service. Most modern analytics (Google Analytics 4, Amplitude, Mixpanel) are designed to handle duplicate events or debounce them server-side. The double-firing in development shouldn't cause production data issues.
If it does matter for your use case, you can use a ref to track if tracking has occurred:
useEffect(() => {
const hasTracked = useRef(false);
if (!hasTracked.current) {
analytics.track('page_viewed', { page: pathname });
hasTracked.current = true;
}
}, [pathname]);
But be cautious: this pattern can hide legitimate bugs. Use it sparingly.
The Boolean Flag Anti-Pattern (And Why to Avoid It)
Some developers, encountering Strict Mode for the first time, reach for this "solution":
// ⚠️ Anti-pattern: Don't do this
useEffect(() => {
let ignore = false;
const run = async () => {
const data = await fetchData();
if (!ignore) {
setData(data);
}
};
run();
return () => { ignore = true };
}, []);
Wait—this pattern is actually correct for async data fetching! The ignore flag prevents setting state after unmount. But developers sometimes misapply it:
// ❌ Wrong: This defeats the purpose of Strict Mode
const hasRun = useRef(false);
useEffect(() => {
if (hasRun.current) return;
hasRun.current = true;
// Effect logic...
}, []);
This pattern says "only run once ever, even in Strict Mode." Why is this problematic?
- It hides bugs. If your effect needs cleanup but doesn't have it, this pattern masks the issue during development.
- It breaks in production scenarios. What if your component legitimately remounts? The effect won't run again.
- It violates React's expectations. Effects should be resilient to multiple invocations by design.
Bottom line: If you find yourself reaching for "run only once" patterns, ask yourself: why does my effect break when it runs twice? The answer usually reveals a cleanup function you forgot to write.
React 19's New Patterns: Server Actions and the use Hook
React 19 introduces patterns that sidestep some of these effect pitfalls entirely. Understanding these can modernize your data fetching approach.
The use Hook for Data Fetching
React 19 introduces the use hook for consuming promises and context:
import { use, Suspense } from 'react';
function UserProfile({ userPromise }) {
// `use` unwraps the promise
const user = use(userPromise);
return <div>{user.name}</div>;
}
// Parent component
function App({ userId }) {
const [userPromise] = useState(() => fetchUser(userId));
return (
<Suspense fallback={<Loading />}>
<UserProfile userPromise={userPromise} />
</Suspense>
);
}
Why this matters: The promise is created once in the parent and passed down. The child component doesn't manage the fetching lifecycle—it just reads the result. This completely avoids the "double fetch" problem because there's no effect involved.
Server Actions for Mutations
For data mutations, React 19's Server Actions provide a different model:
// actions.js - runs on the server
'use server';
export async function createUser(formData) {
const user = await db.users.create({
name: formData.get('name'),
email: formData.get('email'),
});
return user;
}
// Component - no useEffect needed for submission
import { createUser } from './actions';
function CreateUserForm() {
return (
<form action={createUser}>
<input name="name" />
<input name="email" />
<button type="submit">Create</button>
</form>
);
}
Server Actions are invoked as form actions, eliminating the need for submit handlers and effects. The action runs on the server, and React handles updating the UI.
When You Still Need useEffect
These new patterns are powerful, but useEffect isn't going away. You still need it for:
- DOM measurements (reading element dimensions)
- Browser API subscriptions (IntersectionObserver, ResizeObserver)
- Third-party library integration
- Animations triggered by state changes
- WebSocket connections
- Timer/interval management
For these cases, the cleanup function pattern remains essential.
Debugging Strategy: Is It Strict Mode or a Real Bug?
When you see double invocations, here's a systematic debugging approach:
Step 1: Confirm Strict Mode is Active
Check your entry point (index.js, main.jsx, main.tsx):
<StrictMode>
<App />
</StrictMode>
If StrictMode isn't there, double invocations indicate a real bug—likely in your routing or parent component logic.
Step 2: Add Logging to Understand the Flow
useEffect(() => {
console.log('Effect SETUP for:', userId);
return () => {
console.log('Effect CLEANUP for:', userId);
};
}, [userId]);
In Strict Mode you'll see:
Effect SETUP for: 123
Effect CLEANUP for: 123
Effect SETUP for: 123
This confirms the mount → unmount → remount cycle.
Step 3: Test in Production Mode
Run a production build locally:
npm run build
npm run preview # or equivalent for your setup
In production, Strict Mode is disabled. If the double invocation persists, you have an actual bug.
Step 4: Check Your Dependencies Array
A common cause of unexpected re-runs is unstable dependencies:
// ❌ Creates new object every render → effect runs every render
useEffect(() => {
// ...
}, [{ some: 'object' }]);
// ✅ Stable primitive
useEffect(() => {
// ...
}, [userId]);
Objects, arrays, and functions defined inline during render are new on every render, causing effects to re-run.
Performance Implications: Should You Worry?
A natural concern: "Won't this double execution slow down my app?"
In development: Yes, marginally. But development speed inherently differs from production. The safety benefits outweigh the milliseconds.
In production: Strict Mode is completely stripped. There's zero performance impact. Your effect runs exactly once per mount/dependency change.
The React team regularly reminds developers: do not optimize for development performance. The dev build includes many checks, warnings, and intentional slowdowns that don't exist in production.
Best Practices for Resilient Effects
Let's consolidate everything into a set of actionable best practices:
1. Always Return a Cleanup Function
Make it a habit. Even if you think you don't need cleanup:
useEffect(() => {
// Setup logic
return () => {
// Cleanup logic (even if empty comment)
// This forces you to think about what needs cleaning up
};
}, [deps]);
2. Use AbortController for All Fetch Calls
This pattern should be second nature:
useEffect(() => {
const controller = new AbortController();
fetch(url, { signal: controller.signal })
.then(/* ... */)
.catch(err => {
if (err.name !== 'AbortError') throw err;
});
return () => controller.abort();
}, [url]);
3. Store Mutable References in Refs
When effects need mutable state that persists across cleanup cycles:
const socketRef = useRef(null);
useEffect(() => {
socketRef.current = new WebSocket(url);
return () => socketRef.current?.close();
}, [url]);
4. Extract Custom Hooks for Reusable Patterns
Centralize correct behavior:
function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
setLoading(true);
fetch(url, { signal: controller.signal })
.then(res => {
if (!res.ok) throw new Error(res.statusText);
return res.json();
})
.then(setData)
.catch(err => {
if (err.name !== 'AbortError') setError(err);
})
.finally(() => setLoading(false));
return () => controller.abort();
}, [url]);
return { data, loading, error };
}
5. Consider React Query or SWR
For complex data fetching, these libraries handle caching, deduplication, and cleanup automatically:
// Using React Query
import { useQuery } from '@tanstack/react-query';
function UserProfile({ userId }) {
const { data: user, isLoading, error } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json()),
});
if (isLoading) return <Loading />;
if (error) return <Error error={error} />;
return <div>{user.name}</div>;
}
React Query deduplicates requests automatically—even with Strict Mode's double mount, you'll see only one network request.
Edge Cases and Advanced Scenarios
Race Conditions in Sequential Updates
Consider a user rapidly switching between profiles:
// User clicks: Profile A → Profile B → Profile C (rapid clicks)
Without proper cleanup, you might see:
- Request A starts
- Request B starts
- Request C starts
- Request A completes → sets state to User A
- Request C completes → sets state to User C
- Request B completes → sets state to User B ← Wrong!
The user expects to see User C, but User B was the last to complete.
AbortController fixes this: when the user clicks to Profile B, Request A is aborted. When they click to Profile C, Request B is aborted. Only Request C completes.
Effects That Should Run on Mount Only
Sometimes you genuinely need "mount-only" logic that shouldn't re-run. Use a ref carefully:
const initialized = useRef(false);
useEffect(() => {
// Run setup that should only happen once
initializeComplexLibrary();
if (!initialized.current) {
initialized.current = true;
// One-time setup like registering the app with a backend
registerAppInstance();
}
return () => {
// But always clean up
cleanupComplexLibrary();
};
}, []);
Note: the cleanup still runs every time. The ref only guards the "one-time" registration logic.
Synchronizing with External Systems
When your effect syncs state with something outside React (DOM, external API, browser storage):
useEffect(() => {
// Read external state on mount
const savedTheme = localStorage.getItem('theme');
if (savedTheme) setTheme(savedTheme);
// No cleanup needed for reading
// But if you set up listeners, clean them up
const handler = (e) => {
if (e.key === 'theme') setTheme(e.newValue);
};
window.addEventListener('storage', handler);
return () => window.removeEventListener('storage', handler);
}, []);
Disabling Strict Mode: Should You?
You can remove StrictMode:
// Before
<StrictMode>
<App />
</StrictMode>
// After
<App />
Should you? Almost never. Here's why:
- You're hiding bugs, not fixing them. Those bugs will manifest in production.
- You lose future-proofing. Next React features may rely on resilient effects.
- It's a code smell. If you need to disable Strict Mode, your effects likely have structural problems.
The only legitimate reason to temporarily disable Strict Mode is when debugging to isolate whether an issue is Strict Mode-related or a separate bug.
Conclusion: Embrace the Double Invocation
React 19's Strict Mode isn't working against you—it's training you to write better code. Every time you encounter a double invocation, ask yourself:
- Does my effect have a cleanup function?
- Is my cleanup function actually cleaning up what the effect set up?
- Would my effect work correctly if it ran 3 times? 10 times? 100 times?
If the answer to all three is "yes," your effect is production-ready.
The patterns in this guide—AbortController for fetches, cleanup functions for subscriptions, refs for mutable state—aren't just Strict Mode workarounds. They're the correct way to write effects, period. Strict Mode just makes their importance undeniable.
Next time your effect fires twice, don't reach for a workaround. Thank Strict Mode for catching a bug before your users did, and write a cleanup function. Your future self—debugging a production issue at 2 AM—will thank you.
💡 Note: This article was originally published on the Pockit Blog.
Check out Pockit.tools for 50+ free developer utilities (JSON Formatter, Diff Checker, etc.) that run 100% locally in your browser.
Top comments (0)