Fix useEffect Running Twice in React 18 — Strict Mode
Why useEffect runs twice in development and how to fix it with cleanup, AbortController, or useRef — without disabling Strict Mode.
· Updated
You add a useEffect with an empty dependency array, drop a console.log('effect ran') inside, and on first mount you see:
Effect ran
Effect ranOr in the network tab, the same fetch fires twice:
Fetching data...
Fetching data...The instinct is to "fix" it so the effect runs once. That instinct is wrong — the double run is React telling you something is about to break. Here is what it is telling you, and the pattern that makes it stop mattering.
It's not a bug — it's Strict Mode#
React 18 Strict Mode, on by default in every dev environment (Create React App, Next.js App Router, Vite), deliberately remounts your component once on initial render to stress-test your cleanup logic. The double log is not a defect in your code or in React — it is a safety probe. It surfaces effects that are not idempotent, so you find them in dev instead of in production. In a production build (npm run build) Strict Mode is disabled and the effect runs once.
The probe catches the patterns that hurt most: a fetch with no cancellation that races when a component unmounts mid-request, a Supabase Realtime subscribe() with no unsubscribe() leaking listeners, a timer with no clearInterval. Naive effects that "work" in dev only because Strict Mode happens to be off would silently corrupt state under real network latency, slow devices, or hot-reload.
The mount-unmount-remount cycle#
Under the hood, React does three steps on the very first render of a component in dev:
- Mounts the component → runs
useEffect. - Immediately unmounts it → runs your cleanup (if you returned one).
- Remounts it → runs
useEffectagain.
That is the entire mechanism. A typical effect that fails the probe looks like this:
// src/app/page.tsx
'use client';
import { useEffect, useState } from 'react';
export default function Page() {
const [data, setData] = useState<string | null>(null);
useEffect(() => {
console.log('Effect ran'); // ← logs twice in dev
// ❌ Non-idempotent: no cleanup, no cancellation
fetch('/api/data')
.then(res => res.json())
.then(json => setData(json.message));
}, []);
return <div>{data ?? 'Loading...'}</div>;
}fetch is not cancellable by default, so when Strict Mode unmounts after step 1, the in-flight request keeps going; step 3 starts a second request; both resolve and overwrite state — a race. The same shape breaks Supabase Realtime: two subscribe() calls without unsubscribe() in cleanup create duplicate listeners and a memory leak. I cover that case in Optimistic UI Patterns with Next.js Server Actions and Supabase Realtime.
The idempotent effect pattern#
The fix is to make the effect cancellable so the first invocation's work is discarded when Strict Mode unmounts it:
// src/app/page.tsx
'use client';
import { useEffect, useState } from 'react';
export default function Page() {
const [data, setData] = useState<string | null>(null);
useEffect(() => {
// ✅ Local flag — belongs to THIS effect invocation only
let active = true;
const controller = new AbortController();
const fetchData = async () => {
try {
const res = await fetch('/api/data', { signal: controller.signal });
const json = await res.json();
if (active) {
setData(json.message);
}
} catch (err) {
if (err.name !== 'AbortError') {
console.error('Fetch failed:', err);
}
}
};
fetchData();
// ✅ Cleanup cancels the in-flight request and marks this invocation stale
return () => {
active = false;
controller.abort();
};
}, []);
return <div>{data ?? 'Loading...'}</div>;
}The detail that matters: active is declared inside the effect, so each invocation has its own independent flag. When Strict Mode runs cleanup between the first and second mount, it sets the first invocation's active to false and aborts its request — the second mount starts fresh with active = true. A shared useRef across invocations would not work: the first effect's cleanup would set the ref to false before the second effect's fetch resolves, silently dropping a valid response.
Steps:
- Inside the
useEffectbody, declarelet active = trueandconst controller = new AbortController(). - Pass
signal: controller.signalto everyfetchinside the effect. - Guard all
setStatecalls withif (active). - Return a cleanup that sets
active = falseand callscontroller.abort().
Confirm it's gone in production#
npm run devWith the corrected pattern, the first fetch is aborted (AbortError is swallowed) and only the second mount's fetch completes, setting state exactly once.
npm run build && npm run startThe double-mount cycle disappears entirely — Strict Mode is off in production, so the effect runs once and the single fetch resolves normally.
If double logs persist in dev after this, check whether your Supabase client is reinitializing on every render. That happens when createClient is called inside a component — move it to a module-level constant or wrap it in useMemo.
Don't disable Strict Mode#
You will find blog posts suggesting this:
// ❌ NEVER do this
if (process.env.NODE_ENV === 'development') {
React.StrictMode = null;
}Or stripping <React.StrictMode> from root.render(...). This is dangerous. Strict Mode catches real bugs — missing cleanup, race conditions, non-idempotent effects — before they reach production. I have seen teams ship duplicate API calls to production because they disabled Strict Mode to silence the double log.
Treat the double execution as a feature flag: if your effect runs twice in dev, it will break under real conditions. Fix it; do not silence it. I go deeper on React 18's stricter behavior in React Server Components: Complete Deep Dive, including how Server Components sidestep this entirely by not running effects on the server.
useLayoutEffect follows the same rule#
useLayoutEffect runs synchronously after DOM mutations but before paint, with the same double-execution behavior in development. Because it blocks rendering, it is more prone to layout thrashing — so the cleanup discipline matters even more:
import { useLayoutEffect } from 'react';
// ✅ Same effect, with cleanup
useLayoutEffect(() => {
const handleResize = () => console.log('Resized');
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);In production both useEffect and useLayoutEffect run once. In development both run twice, and both require cleanup.
Spot leaks with a render counter#
To see the cycle explicitly, log render and cleanup counts:
// src/app/page.tsx
import { useEffect, useRef } from 'react';
export default function Page() {
const renderCount = useRef(0);
renderCount.current += 1;
useEffect(() => {
console.log(`Effect #${renderCount.current} ran`);
return () => {
console.log(`Cleanup after effect #${renderCount.current}`);
};
}, []);
return <div>Render count: {renderCount.current}</div>;
}In development:
Effect #1 ran
Cleanup after effect #1
Effect #2 ranIn production:
Effect #1 ranIf you see cleanup logs in production, something is wrong — likely a re-render from state changes, not a remount.
FAQ#
Why does useEffect only run twice in development and not in production?#
Strict Mode is disabled in production builds. React runs the double-mount only in development to catch side effects — it is a dev-time probe, not runtime behavior.
How can I prevent an API call from firing twice on page load?#
Use AbortController to cancel pending requests on cleanup, and guard state updates with a local let active = true flag declared inside the effect. Never assume the first request will "win".
Does useEffect running twice affect performance in production?#
No. Strict Mode is off in production; effects run once. The double execution is purely a development-time safety net.
How do I properly clean up a subscription or timer in useEffect?#
Return a cleanup function:
useEffect(() => {
const id = setInterval(() => console.log('Tick'), 1000);
return () => clearInterval(id);
}, []);Is using a ref to stop the second execution of useEffect bad practice?#
No — useRef for an isMounted flag is standard when you cannot cancel the side effect (e.g., third-party libraries without cancellation APIs). Prefer AbortController when possible.
Why is my console.log appearing twice even with an empty dependency array?#
Strict Mode remounts the component in development. Empty dependencies only prevent re-runs on subsequent renders — not the initial double-mount.
How do I handle state updates that trigger another useEffect run (infinite loops)?#
Check for accidental dependencies — e.g., passing a new object/array literal as a dependency. Use useMemo or useCallback to stabilize references. I cover this in Next.js Performance Optimization: 10 Essential Techniques.
Related#
- React Server Components: Complete Deep Dive
- Optimistic UI Patterns with Next.js Server Actions and Supabase Realtime
- Next.js Hydration Mismatch: 8 Fixes for App Router (2026)
- Next.js Performance Optimization: 10 Essential Techniques
- Fix React 18 hydration mismatch in Next.js — the other symptom of the same React 18 change. If the double effect also leaves the first paint disagreeing with the server HTML, fix the mismatch before the effect.
Related fixes & guides
- Fix Next.js 'Cannot Have a Negative Time Stamp' Error
- Next.js "Server Is Running Out of Memory" Fix (2026)
- Fix "Parsing error: Cannot find module next/babel"
- Next.js 15 Partial Prerendering: Guide
- Next.js App Router Guide: From Basics to Advanced Patterns
- Window is not defined in Next.js – 2026 Fix for React Apps
- I Tanked My Core Web Vitals Score With Next.js Images
- Next.js + Supabase Performance: 7 Fixes Cut Load 70%
- JSX.Element vs ReactNode vs ReactElement: TS2322 Fix
- Why useEffect runs twice in Next.js dev