← 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.

· Updated

You add a useEffect with an empty dependency array, drop a console.log('effect ran') inside, and on first mount you see:

plaintext
Effect ran
Effect ran

Or in the network tab, the same fetch fires twice:

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

  1. Mounts the component → runs useEffect.
  2. Immediately unmounts it → runs your cleanup (if you returned one).
  3. Remounts it → runs useEffect again.

That is the entire mechanism. A typical effect that fails the probe looks like this:

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

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:

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

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:

  1. Inside the useEffect body, declare let active = true and const controller = new AbortController().
  2. Pass signal: controller.signal to every fetch inside the effect.
  3. Guard all setState calls with if (active).
  4. Return a cleanup that sets active = false and calls controller.abort().

Confirm it's gone in production#

bash
npm run dev

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

bash
npm run build && npm run start

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

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

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

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:

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

In production:

plaintext
Effect #1 ran

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

js
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 fixes & guides