← Back to Fixes

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:

plaintext
Effect ran
Effect ran

Or in the browser console:

plaintext
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 stress-tests your cleanup logic — verifying that side effects are properly cancelled when a component unmounts — and catches effects that aren’t idempotent.

Under the hood, React does this:

  1. Mounts the component → runs useEffect
  2. Immediately unmounts it → runs cleanup (if defined)
  3. Remounts it → runs useEffect again

This only occurs in development. In production (npm run build), Strict Mode is disabled, and effects run once.

The relevant code path is:

js
// 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#

js
// 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>;
}

This fixes the core problem: active is declared inside the effect, so each invocation has its own independent flag. When Strict Mode runs the 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 multiple effects would not work here: the first effect’s cleanup would set the ref to false before the second effect’s fetch resolves, silently dropping the response.

Step by step#

  1. Open your component file (e.g., src/app/page.tsx).
  2. Inside the useEffect body, declare let active = true and const controller = new AbortController().
  3. Pass signal: controller.signal to every fetch call inside the effect.
  4. Guard all setState calls with if (active).
  5. Return a cleanup that sets active = false and calls controller.abort().

Verify the fix#

Run:

bash
npm run dev

With the corrected pattern, the first fetch is aborted (AbortError is swallowed silently) and only the second mount's fetch completes, setting state exactly once.

In production:

bash
npm run build && npm run start

The double-mount cycle disappears entirely — Strict Mode is disabled in production builds, so the effect runs once and the single fetch resolves normally.

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 useEffect with a cleanup function when your effect starts async work or subscribes to an external source.
  • Use AbortController for all fetch calls 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:

js
// ❌ NEVER do this
if (process.env.NODE_ENV === 'development') {
  React.StrictMode = null;
}

Or in index.tsx:

js
// ❌ Also bad — disables Strict Mode for the whole app
root.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

Then later:

js
// ❌ 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:

js
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:

js
// 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:

plaintext
Effect #1 ran
Cleanup after effect #1
Effect #2 ran

This confirms Strict Mode’s remount cycle. In production, only:

plaintext
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 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 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:

js
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