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.
TL;DR#
If you see console.log('effect ran') print twice on initial mount — even with [] dependencies — it’s React 18 Strict Mode deliberately remounting your component to catch side effects. The fix is to make your effect idempotent: add a cleanup function, cancel pending requests with AbortController, or guard with useRef.
If that doesn’t resolve it, check whether you’re accidentally triggering re-renders inside the effect — or whether your Supabase client is reinitializing on every render.
What you'll see#
Paste the exact behavior you’re seeing — this is what developers search for on Stack Overflow:
Effect ran
Effect ran
Or in the browser console:
Fetching data...
Fetching data...
It happens when you mount a component in development mode (npm run dev) with an empty dependency array — useEffect(() => { console.log('Effect ran'); }, []). The behavior is identical across Create React App, Next.js App Router, and Vite setups — anywhere Strict Mode is enabled.
Strict Mode is on by default in React 18 dev environments. It’s not a bug — it’s intentional. But it breaks naive assumptions about effect execution, especially when combined with Supabase client initialization or API calls that lack cancellation.
Root cause#
React 18 introduced a new development-only behavior: when Strict Mode is enabled, React mounts, unmounts, and remounts components once on initial render. This simulates what happens during hot-reloading or component remounting in real apps — and catches side effects that aren’t idempotent.
Under the hood, React does this:
- Mounts the component → runs
useEffect - Immediately unmounts it → runs cleanup (if defined)
- Remounts it → runs
useEffectagain
This only occurs in development. In production (npm run build), Strict Mode is disabled, and effects run once.
The relevant code path is:
// 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>;
}
This pattern fails because fetch is not cancellable by default. If the first request hasn’t completed when the component unmounts, the second mount triggers a second request — and both resolve, potentially overwriting state or causing race conditions.
I’ve seen this break Supabase Realtime subscriptions too: two subscribe() calls without unsubscribe() in cleanup cause duplicate event listeners and memory leaks. I cover how to fix this in Optimistic UI Patterns with Next.js Server Actions and Supabase Realtime.
The Mounting/Unmounting Cycle#
React 18 Strict Mode introduces a specific behavior known as the Mounting/Unmounting cycle. In this cycle, React intentionally mounts a component, immediately unmounts it, and then remounts it again. This process is designed to stress-test your component's logic, ensuring that side effects like event listeners, subscriptions, or data fetching are properly cleaned up when the component is removed. By simulating this cycle in development, React helps you identify memory leaks and race conditions before they reach production.
The fix#
// src/app/page.tsx
'use client';
import { useEffect, useRef, useState } from 'react';
export default function Page() {
const [data, setData] = useState<string | null>(null);
const isMounted = useRef(true);
useEffect(() => {
console.log('Effect ran once per mount cycle');
// ✅ Cleanup function cancels pending work
return () => {
isMounted.current = false;
};
}, []);
// Example: Supabase-style API call with AbortController
useEffect(() => {
const controller = new AbortController();
const fetchData = async () => {
try {
const res = await fetch('/api/data', { signal: controller.signal });
const json = await res.json();
if (isMounted.current) {
setData(json.message);
}
} catch (err) {
if (err.name !== 'AbortError') {
console.error('Fetch failed:', err);
}
}
};
fetchData();
// ✅ Cleanup cancels pending request
return () => {
controller.abort();
isMounted.current = false;
};
}, []);
return <div>{data ?? 'Loading...'}</div>;
}
That single change addresses the cause because AbortController.signal cancels the fetch when the component unmounts, and isMounted.current guards state updates — ensuring only the latest mount’s effect updates state.
Step by step#
- Open your component file (e.g.,
src/app/page.tsx). - Add
useRef(true)before the firstuseEffect. - In the effect’s cleanup, set
isMounted.current = false. - Wrap state updates in
if (isMounted.current). - For API calls, instantiate
AbortController, passsignal, and callabort()in cleanup.
Verify the fix#
Run:
npm run dev
You should see:
Effect ran once per mount cycle
Effect ran once per mount cycle
But crucially — in production:
npm run build && npm run start
The double execution disappears. Only one log appears — because Strict Mode is disabled.
If you’re still seeing double logs in development, check whether your Supabase client is reinitializing on every render. I’ve seen this happen when createClient is called inside a component — move it to a module-level constant or wrap it in useMemo.
Why this happens (and how to avoid it next time)#
The deeper invariant: side effects must be idempotent or cancellable. React’s Strict Mode is a warning system — it’s telling you your effect has side effects that could break if the component unmounts mid-operation.
Adopt these habits to prevent regression:
- Always pair
useEffectwith a cleanup function — even if it’s empty. React warns if you omit it in strict linting. - Use
AbortControllerfor allfetchcalls inside effects. It’s built for this. - Never disable Strict Mode globally. It’s a critical safety net — especially in Next.js apps where hydration mismatches can cause Next.js Hydration Mismatch Error: Exact Fixes for App Router and React.
I cover React 18’s stricter behavior in depth in React Server Components: Complete Deep Dive, including how Server Components avoid this issue entirely by not running effects on the server.
Common Pitfalls: Why disabling Strict Mode is a mistake#
You’ll find blog posts suggesting this:
// ❌ NEVER do this
if (process.env.NODE_ENV === 'development') {
React.StrictMode = null;
}
Or in index.tsx:
// ❌ Also bad — disables Strict Mode for the whole app
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
Then later:
// ❌ Removing StrictMode entirely
root.render(<App />);
This is dangerous. Strict Mode catches real bugs — like missing cleanup, race conditions, and non-idempotent effects — before they hit production. I’ve seen teams ship duplicate API calls to production because they disabled Strict Mode to “fix” the double log.
Instead, treat the double execution as a feature flag: if your effect runs twice in dev, it will break under real-world conditions (e.g., network latency, slow devices, hot-reload). Fix it — don’t silence it.
Comparison: useEffect vs. useLayoutEffect timing#
useLayoutEffect runs synchronously after DOM mutations but before paint — same double-execution behavior in development. But because it blocks rendering, it’s more likely to cause layout thrashing if you’re not careful.
Example:
import { useLayoutEffect } from 'react';
// ❌ useLayoutEffect with non-idempotent side effect
useLayoutEffect(() => {
window.addEventListener('resize', handleResize);
}, []);
// ✅ 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 to avoid memory leaks.
Verification: Testing for memory leaks and redundant renders#
Run this in your component:
// 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, you’ll see:
Effect #1 ran
Cleanup after effect #1
Effect #2 ran
This confirms Strict Mode’s remount cycle. In production, only:
Effect #1 ran
If you see cleanup logs in production, something is wrong — likely a re-render triggered by state changes, not remounting.
FAQ#
Why does useEffect only run twice in development and not in production?#
Strict Mode is disabled in production builds. React only runs the double-mount in development to catch side effects — it’s a linting tool, 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 useRef. Never assume the first request will “win”.
Does useEffect running twice affect performance in production?#
No. Strict Mode is disabled 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?#
Always 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 a bad practice?#
No — useRef for isMounted is a standard pattern when you can’t cancel the side effect (e.g., third-party libraries without cancellation APIs). But prefer cancellation (AbortController) when possible.
Why is my console.log appearing twice even though I have an empty dependency array?#
Because React 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.
By mastering the mounting/unmounting cycle and implementing proper cleanup strategies, you ensure your React applications are robust and performant. For deeper insights into specific frameworks and optimization techniques, refer to the guides on Next.js hydration fixes, performance benchmarks, and Supabase Realtime patterns.
Related fixes & guides
- Next.js Hydration Mismatch Error: Production Fix Guide
- Next.js revalidatePath Not Working: Production Fix Guide for App Router
- Next.js Server Actions Debugging Matrix: Problem to Fix Production Guide
- Next.js App Router Guide: From Basics to Advanced Patterns
- Window is not defined in Next.js – 2026 Fix for React Apps
- NextJS Warning: Extra attributes from the server – Fix 2026
- Why useEffect runs twice in Next.js dev
- Fix React 18 Hydration Mismatch in Next.js
- Fix port setting in Next.js
- Fix Next.js 16: Disable Turbopack Production Build (2026)