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

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:

  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, 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#

  1. Open your component file (e.g., src/app/page.tsx).
  2. Add useRef(true) before the first useEffect.
  3. In the effect’s cleanup, set isMounted.current = false.
  4. Wrap state updates in if (isMounted.current).
  5. For API calls, instantiate AbortController, pass signal, and call abort() in cleanup.

Verify the fix#

Run:

bash
npm run dev

You should see:

Effect ran once per mount cycle
Effect ran once per mount cycle

But crucially — in production:

bash
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 useEffect with a cleanup function — even if it’s empty. React warns if you omit it in strict linting.
  • 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:

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:

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