React — Effects Fire Twice in Development Under StrictMode
Understand why React StrictMode double-invokes effects in development, why subscriptions and requests duplicate, and how to make cleanup fully symmetric.
Symptoms
- useEffect bodies run twice on mount in development: two requests, two subscriptions, doubled logs
- Production behaves correctly while development shows duplicated side effects
- Warnings about state updates on unmounted components accompany the double run
Possible causes
- React 18 StrictMode intentionally mounts, unmounts, and remounts components to surface missing cleanup
- The effect establishes a subscription or fires a request but its cleanup does not fully undo it
- Event listeners, intervals, or observers persist after cleanup because the return function is missing or partial
- Non-idempotent side effects (analytics events, payment intents) fire per effect run rather than per logical mount
Diagnosis steps
Confirm the double invocation
npm run dev # React 18 dev build; add console.log in the effect and cleanupInterpretation
log → cleanup → log sequence in the console is the StrictMode remount cycle working exactly as designed.
Audit effect cleanup symmetry
grep -Rn -A6 "useEffect(" src/ | grep -B2 -A4 "return" | head -40Interpretation
Every effect with a side effect needs a cleanup that undoes it; effects without returns are the duplication candidates.
Classify side effects by idempotency
grep -Rn "track(\|logEvent(\|createIntent(" src/Interpretation
Analytics and payment calls must move to idempotent patterns or fire once per logical action, not per effect execution.
Root cause
React 18 StrictMode remounts each component once in development to verify that effects clean up after themselves. This is deliberate: resilient applications survive mount-unmount-mount cycles, and the double invocation exposes any effect whose cleanup does not fully reverse its setup.
Fix
Write fully symmetric effects: whatever setup does, cleanup undoes (subscribe/unsubscribe, start/stop timer, abort in-flight fetch). Move non-idempotent side effects to event handlers, and treat StrictMode as a free test of cleanup correctness rather than disabling it.
Risk note
Disabling StrictMode silences the symptom while leaving broken cleanup in production, where route changes and Suspense remounts trigger the same bugs.
Verify the fix
With StrictMode on, no duplicate subscriptions remain (listener counts stable), requests show one in-flight then one aborted then one completed, and analytics fire once per logical action.
Symptom walkthrough
The console tells the story with three lines:
subscribe <- effect body 1
cleanup <- StrictMode unmount
subscribe <- effect body 2
That is StrictMode doing its job: mount, unmount, remount, so that a missing cleanup becomes visible immediately. The diagnostic question for every duplicated effect is: does my cleanup undo my setup, completely? A missing AbortController, an un-cleared interval, an analytics call with no compensating logic — each surfaces here first.
Fix in practice
Symmetry is the rule:
useEffect(() => {
const controller = new AbortController();
fetch(url, { signal: controller.signal })
.then(r => r.json())
.then(setData)
.catch(e => { if (e.name !== 'AbortError') setError(e); });
return () => controller.abort(); // fully undoes the setup
}, [url]);
useEffect(() => {
const handler = (e) => setPos(e);
window.addEventListener('pointermove', handler);
return () => window.removeEventListener('pointermove', handler);
}, []);
Non-idempotent actions belong to user intent, not to mounting:
function onCheckout() {
track('checkout_started', { cart }); // once per click, not per mount
createPaymentIntent(cart);
}
Verification
With StrictMode still enabled, run the app in development and confirm the console sequence is subscribe, cleanup, subscribe with no second live subscription: count listeners with getEventListeners(window) in DevTools, or assert your store’s subscriber count returns to the same value after the remount cycle. The Network pane shows one in-flight request, one aborted request, one completed request — the aborted fetch is the StrictMode remount working, not a bug. Analytics and payment intents fire exactly once per logical action because they live in event handlers. Do not disable StrictMode to silence the noise: the same missing-cleanup bugs resurface in production through route changes and Suspense remounts, where nothing warns you.