Fix useEffect Running Twice in React 18 — Strict Mode

Strict Mode checks effect cleanup in development. Release subscriptions and cancel or ignore obsolete requests instead of blocking the second setup.

Mahdi Ben RhoumaMahdi Ben Rhouma · 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...

Before changing the effect, identify whether Strict Mode's development check explains the extra setup. A repeated log alone is not proof that your code is broken. Check that each setup can be followed by cleanup and another setup without retaining unwanted subscriptions or applying obsolete results.

It's not a bug — it's Strict Mode#

When Strict Mode is enabled at the root, React runs an extra development setup-and-cleanup cycle for effects. Its Strict Mode reference explains this check and the distinction for a boundary enabled below the root. Do not assume every project template or configuration enables it identically. Production omits this extra probe; ordinary mounts, dependency changes and unmounts still run their normal effect lifecycle.

The useful question is what survives cleanup. A subscription that remains connected can keep delivering events. A request can finish after its result has become obsolete. These need different checks: release persistent resources and ensure that obsolete responses cannot update the current selection. A finite Promise completing after unmount does not, by itself, establish a memory leak.

The mount-unmount-remount cycle#

For the initial effect probe under root Strict Mode in development, observe the effect lifecycle as:

  1. Run setup.
  2. Run its cleanup, if supplied.
  3. Run setup again.

This describes the effect check, not a guarantee that every underlying DOM node is physically removed and recreated. An unguarded fetching effect can start work during both setups:

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

Without a signal or stale-result guard, the first request can continue after cleanup. If requests represent different inputs and finish out of order, an old value can overwrite a newer value. A forgotten subscription is a separate resource problem. The unmounted-component warning explanation distinguishes these cases and shows why checking whether any effect is mounted is insufficient to identify the current request.

The idempotent effect pattern#

Make each effect execution responsible for its own work. This example assumes your application has a JSON endpoint returning a message. It cancels the fetch when possible and independently ignores an obsolete result:

tsx
// 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 (active && !(err instanceof Error && 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 active variable belongs to one execution. Cleanup invalidates that execution; the next setup receives a fresh variable. A shared ref left false can suppress a valid new result. Resetting that same ref to true introduces another problem: an older callback may see true and update the new selection. The effect-local flag avoids both ambiguities. It does not stop the second setup; it makes the old setup harmless after cleanup.

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 extra development probe is absent in a production build. For this empty-dependency example, an ordinary mount starts one setup. Navigating away still triggers cleanup; navigating back can mount a new instance. Effects with dependencies also clean up and rerun when those dependencies change. Count the lifecycle events appropriate to your application rather than expecting one request for its entire lifetime.

Development setup logs can still appear twice after the correction: cleanup does not disable the probe. Check subscriptions, visible data and the lifetime of each request. If effects repeat beyond the expected probe, inspect dependency identities and actual component mounts before changing client creation or moving state elsewhere.

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. Disabling it to silence the double log is how a non-idempotent effect (a duplicate API call, for example) ships to production undetected.

A repeated development setup is an opportunity to test cleanup, not a prediction that every effect will fail. An effect that registers and removes the same listener can pass this check while still logging both setups. In a Next.js application, keep the Client Component boundary explicit for hook-based UI; moving a hook into a Server Component is not a way to repair its cleanup.

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);
}, []);

Both hooks follow their dependency and mount lifecycle. The development probe is additional to that lifecycle; cleanup is still normal in production. Choose the hook for its timing requirements, not as a way to avoid the check.

Inspect setup and cleanup without mutating a render counter#

Log the effect operations directly. A render counter mixes rendering with effect execution and does not establish which setup created a resource:

js
// src/app/page.tsx
'use client';
 
import { useEffect } from 'react';
 
export default function Page() {
  useEffect(() => {
    console.log('setup');
    return () => {
      console.log('cleanup');
    };
  }, []);
 
  return <div>Effect probe</div>;
}

On 3 November 2026, a Docker test with React 19.2.4, Node 22.23.4 and jsdom recorded this initial sequence under root Strict Mode in development:

plaintext
setup
cleanup
setup

Unmounting that root then added the final cleanup. A separate controlled request-ordering test changed only the effect-local invalidation guard: an old response replaced the new selection before the guard, while the newer selection remained after it. These tests checked effect execution and DOM state, not browser network cancellation or server-side deduplication.

In production, the documented ordinary initial mount omits the extra probe:

plaintext
setup

Cleanup on unmount is expected in production. With non-empty dependencies, cleanup before the next setup is expected when a dependency changes too. If the logs are surprising, identify which component instance and dependency values belong to them. A state-driven re-render does not by itself rerun an empty-dependency effect.

Cancellation cannot guarantee that a request never reached the server. If the requirement is deduplication, use an appropriate request cache or application-level design as explained in React's fetching-data guidance. Keep effect dependencies accurate, release resources in cleanup, and check the resulting behaviour instead of suppressing the second setup.

The Supabase-specific version: "Multiple GoTrueClient instances detected"#

If your effect creates a Supabase client instead of just calling one, Strict Mode's extra mount/cleanup/remount cycle can construct it twice, and the console prints:

plaintext
Multiple GoTrueClient instances detected in the same browser context.

This happens with code shaped like this, where createClient() runs inside the component instead of once at module load:

tsx
'use client'
 
import { createClient } from '@supabase/supabase-js'
import { useEffect } from 'react'
 
export default function Profile() {
  useEffect(() => {
    // new client on every mount — including the Strict Mode remount
    const supabase = createClient(url, anonKey)
    supabase.auth.getSession().then(/* ... */)
  }, [])
 
  return null
}

The GoTrueClient constructor warns when it detects additional instances in the browser context. It does not detect an actual storage race: concurrent use under a shared storage key is the risk described by the warning. Diagnose the client instances separately from React's effect probe, using Multiple GoTrueClient instances detected.

The fix that matters here specifically: never call createClient() inside a component or an effect. Create it once in a dedicated module —

ts
// lib/supabase-client.ts
import { createClient } from '@supabase/supabase-js'
export const supabase = createClient(url, anonKey)

— and import that exported browser instance where it is needed. Reusing a client within the same module instance avoids constructing it in every effect setup. This is a browser-client pattern, not a rule to share request-specific server authentication state globally. It is also separate from cancelling a fetch: the earlier request correctly remains inside the effect, with cleanup controlling its lifetime.

It's not just effects: lazy state initializers double-invoke too#

A separate development check can call the initializer passed to useState twice. React's useState reference explains that this checks purity and that one result is ignored. This is different from running effect setup and cleanup: an initializer computes state rather than acquiring a resource that an effect later releases.

tsx
'use client'
 
import { useState } from 'react'
 
export default function Counter() {
  // this function body runs twice in dev under Strict Mode
  const [count, setCount] = useState(() => {
    console.log('computing initial count') // logs twice
    return 0
  })
 
  return <button onClick={() => setCount((c) => c + 1)}>{count}</button>
}

React documents this directly: component bodies, state updater functions, and the functions passed to useState/useReducer/useMemo are all expected to be pure, so Strict Mode calls them twice to surface any hidden side effect the same way it does for effects. The fix is the same principle as everywhere else in this article — keep the initializer pure (just compute and return a value, don't log, mutate a module-level variable, or write to storage inside it) — but it's worth knowing this is a distinct mechanism from the effect double-invocation above, not the same bug wearing a different disguise. If the confusing double log line showed up next to a component that also needed converting to a Client Component in the first place, the setup mistakes that cause that are covered in Fix: component needs useState, no Client parent.

Still stuck? Paste your error.

Paste the error message or stack trace — we check it against every fix and tool on the site.

Matching happens in your browser. When you search, a cleaned error signature is sent so we can spot errors we do not cover yet — never the raw text, your IP address or your e-mail. Do not paste passwords or keys. Details

Related fixes & guides