DEV Community

HK Lee
HK Lee

Posted on • Originally published at pockit.tools

Why is useEffect Running Twice? The Complete Guide to React 19 Strict Mode and Effect Cleanup

You've just created a fresh React project. You write a simple useEffect to fetch some data. You check the console—and there it is: two network requests instead of one. Two console logs. Two of everything.

Your first instinct is probably to panic. Is this a bug? Did you set up something wrong? Should you downgrade React? Search Stack Overflow?

Take a breath. Your code isn't broken. This behavior is intentional, and understanding why it exists will make you a significantly better React developer. More importantly, learning to write effects that work correctly under this behavior will prevent real bugs in production that would otherwise slip past your development testing.

This guide will take you from confusion to mastery. We'll explore exactly what's happening, why the React team made this seemingly frustrating decision, and—most importantly—how to write effects that are resilient, correct, and production-ready.


The Symptom: Double Invocations in Development

Let's start with what you're actually seeing. Consider this component:

import { useEffect, useState } from 'react';

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    console.log('Effect running for user:', userId);

    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(data => setUser(data));
  }, [userId]);

  return <div>{user?.name}</div>;
}
Enter fullscreen mode Exit fullscreen mode

In development mode (with StrictMode enabled), you'll see:

Effect running for user: 123
Effect running for user: 123
Enter fullscreen mode Exit fullscreen mode

And in your Network tab: two identical requests to /api/users/123.

If you've been writing React for a while, this might be new behavior. Prior to React 18, effects ran once in development—just like in production. So what changed?


The Root Cause: React.StrictMode's Double-Invocation

The culprit is React.StrictMode, which wraps your application (usually in index.js or main.jsx):

import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';

createRoot(document.getElementById('root')).render(
  <StrictMode>
    <App />
  </StrictMode>
);
Enter fullscreen mode Exit fullscreen mode

Starting with React 18, and continuing in React 19, Strict Mode deliberately:

  1. Mounts your component
  2. Unmounts it immediately
  3. Remounts it

This happens only in development. In production builds, your effect runs exactly once per mount (unless dependencies change).

Here's the kicker: this intentional double-invocation is designed to expose bugs in your code.


Why Would React Do This? Understanding the Motivation

The React team didn't add this behavior to annoy developers. They added it because of a fundamental truth about effects:

If your effect breaks when it runs twice, it would have broken in production anyway—you just wouldn't have caught it during development.

Think about real-world scenarios where effects can run multiple times:

Scenario 1: Fast Refresh During Development

You're coding, you save a file, and Fast Refresh re-renders your component. If your effect sets up a subscription but doesn't clean it up properly, you now have duplicate subscriptions.

Scenario 2: React Suspense and Transitions

With React 18+ features like startTransition and Suspense, React may need to "suspend" a render, then retry it. Components can mount, unmount, and remount as part of normal concurrent rendering behavior.

Scenario 3: Component Remounting in Real Navigation

User navigates away, back button, component remounts. If your effect creates a WebSocket connection without cleanup, you now have orphaned connections.

Scenario 4: Future Features

The React team is building towards "Offscreen" APIs (rendering components in the background before they're visible). These will require components to mount/unmount multiple times as part of their normal lifecycle.

The double-invocation in Strict Mode simulates these real conditions. If your code survives the Strict Mode stress test, it's much more likely to work correctly in all these scenarios.


The Real Problem: Effects Without Cleanup

Let's diagnose where most developers go wrong. Look at this effect:

useEffect(() => {
  const socket = new WebSocket('wss://api.example.com/realtime');

  socket.onmessage = (event) => {
    setMessages(prev => [...prev, event.data]);
  };
}, []);
Enter fullscreen mode Exit fullscreen mode

What's wrong? There's no cleanup function. When Strict Mode unmounts and remounts, you create a second WebSocket connection. The first one is now orphaned—still receiving messages, still consuming memory, but with no reference to close it.

The correct pattern:

useEffect(() => {
  const socket = new WebSocket('wss://api.example.com/realtime');

  socket.onmessage = (event) => {
    setMessages(prev => [...prev, event.data]);
  };

  // Cleanup function - called on unmount
  return () => {
    socket.close();
  };
}, []);
Enter fullscreen mode Exit fullscreen mode

Now the flow is:

  1. Mount → Create WebSocket #1
  2. Strict Mode unmount → Close WebSocket #1
  3. Remount → Create WebSocket #2

Only one connection exists at a time. Your effect is resilient.


Fixing Common Effect Patterns

Let's walk through the most common effect patterns and how to make them Strict Mode-proof.

Pattern 1: Data Fetching with Abort Controller

The naive approach to fetching data:

// ❌ Problematic: Double fetch, potential race conditions
useEffect(() => {
  fetch(`/api/users/${userId}`)
    .then(res => res.json())
    .then(setUser);
}, [userId]);
Enter fullscreen mode Exit fullscreen mode

The resilient approach:

// ✅ Correct: Aborts in-flight requests on cleanup
useEffect(() => {
  const controller = new AbortController();

  fetch(`/api/users/${userId}`, { signal: controller.signal })
    .then(res => res.json())
    .then(setUser)
    .catch(err => {
      if (err.name !== 'AbortError') {
        console.error('Fetch failed:', err);
      }
    });

  return () => controller.abort();
}, [userId]);
Enter fullscreen mode Exit fullscreen mode

What this achieves:

  • When Strict Mode unmounts, the cleanup aborts the first request
  • The second request proceeds normally
  • In production with fast navigation, old requests don't overwrite new data (the race condition problem)

Pattern 2: Event Listeners on Window/Document

// ❌ Problematic: Multiple listeners accumulate
useEffect(() => {
  window.addEventListener('resize', handleResize);
}, []);
Enter fullscreen mode Exit fullscreen mode
// ✅ Correct: Clean removal
useEffect(() => {
  window.addEventListener('resize', handleResize);
  return () => window.removeEventListener('resize', handleResize);
}, []);
Enter fullscreen mode Exit fullscreen mode

Pattern 3: Timers and Intervals

// ❌ Problematic: Multiple intervals running
useEffect(() => {
  setInterval(() => {
    setCount(c => c + 1);
  }, 1000);
}, []);
Enter fullscreen mode Exit fullscreen mode
// ✅ Correct: Clear the interval
useEffect(() => {
  const id = setInterval(() => {
    setCount(c => c + 1);
  }, 1000);

  return () => clearInterval(id);
}, []);
Enter fullscreen mode Exit fullscreen mode

Pattern 4: Third-Party Library Initialization

Many charting libraries, animation frameworks, or DOM manipulation tools need initialization and destruction:

// ❌ Problematic: Chart initializes twice
useEffect(() => {
  const chart = new ChartLibrary(containerRef.current, {
    data: chartData,
  });
}, [chartData]);
Enter fullscreen mode Exit fullscreen mode
// ✅ Correct: Destroy on cleanup
useEffect(() => {
  const chart = new ChartLibrary(containerRef.current, {
    data: chartData,
  });

  return () => chart.destroy();
}, [chartData]);
Enter fullscreen mode Exit fullscreen mode

Pattern 5: Analytics and Tracking

This is where it gets nuanced. You probably don't want your analytics to de-duplicate:

// This will fire twice in Strict Mode - is that a problem?
useEffect(() => {
  analytics.track('page_viewed', { page: pathname });
}, [pathname]);
Enter fullscreen mode Exit fullscreen mode

The answer: It depends on your analytics service. Most modern analytics (Google Analytics 4, Amplitude, Mixpanel) are designed to handle duplicate events or debounce them server-side. The double-firing in development shouldn't cause production data issues.

If it does matter for your use case, you can use a ref to track if tracking has occurred:

useEffect(() => {
  const hasTracked = useRef(false);

  if (!hasTracked.current) {
    analytics.track('page_viewed', { page: pathname });
    hasTracked.current = true;
  }
}, [pathname]);
Enter fullscreen mode Exit fullscreen mode

But be cautious: this pattern can hide legitimate bugs. Use it sparingly.


The Boolean Flag Anti-Pattern (And Why to Avoid It)

Some developers, encountering Strict Mode for the first time, reach for this "solution":

// ⚠️ Anti-pattern: Don't do this
useEffect(() => {
  let ignore = false;

  const run = async () => {
    const data = await fetchData();
    if (!ignore) {
      setData(data);
    }
  };

  run();

  return () => { ignore = true };
}, []);
Enter fullscreen mode Exit fullscreen mode

Wait—this pattern is actually correct for async data fetching! The ignore flag prevents setting state after unmount. But developers sometimes misapply it:

// ❌ Wrong: This defeats the purpose of Strict Mode
const hasRun = useRef(false);

useEffect(() => {
  if (hasRun.current) return;
  hasRun.current = true;

  // Effect logic...
}, []);
Enter fullscreen mode Exit fullscreen mode

This pattern says "only run once ever, even in Strict Mode." Why is this problematic?

  1. It hides bugs. If your effect needs cleanup but doesn't have it, this pattern masks the issue during development.
  2. It breaks in production scenarios. What if your component legitimately remounts? The effect won't run again.
  3. It violates React's expectations. Effects should be resilient to multiple invocations by design.

Bottom line: If you find yourself reaching for "run only once" patterns, ask yourself: why does my effect break when it runs twice? The answer usually reveals a cleanup function you forgot to write.


React 19's New Patterns: Server Actions and the use Hook

React 19 introduces patterns that sidestep some of these effect pitfalls entirely. Understanding these can modernize your data fetching approach.

The use Hook for Data Fetching

React 19 introduces the use hook for consuming promises and context:

import { use, Suspense } from 'react';

function UserProfile({ userPromise }) {
  // `use` unwraps the promise
  const user = use(userPromise);

  return <div>{user.name}</div>;
}

// Parent component
function App({ userId }) {
  const [userPromise] = useState(() => fetchUser(userId));

  return (
    <Suspense fallback={<Loading />}>
      <UserProfile userPromise={userPromise} />
    </Suspense>
  );
}
Enter fullscreen mode Exit fullscreen mode

Why this matters: The promise is created once in the parent and passed down. The child component doesn't manage the fetching lifecycle—it just reads the result. This completely avoids the "double fetch" problem because there's no effect involved.

Server Actions for Mutations

For data mutations, React 19's Server Actions provide a different model:

// actions.js - runs on the server
'use server';

export async function createUser(formData) {
  const user = await db.users.create({
    name: formData.get('name'),
    email: formData.get('email'),
  });
  return user;
}
Enter fullscreen mode Exit fullscreen mode
// Component - no useEffect needed for submission
import { createUser } from './actions';

function CreateUserForm() {
  return (
    <form action={createUser}>
      <input name="name" />
      <input name="email" />
      <button type="submit">Create</button>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

Server Actions are invoked as form actions, eliminating the need for submit handlers and effects. The action runs on the server, and React handles updating the UI.

When You Still Need useEffect

These new patterns are powerful, but useEffect isn't going away. You still need it for:

  • DOM measurements (reading element dimensions)
  • Browser API subscriptions (IntersectionObserver, ResizeObserver)
  • Third-party library integration
  • Animations triggered by state changes
  • WebSocket connections
  • Timer/interval management

For these cases, the cleanup function pattern remains essential.


Debugging Strategy: Is It Strict Mode or a Real Bug?

When you see double invocations, here's a systematic debugging approach:

Step 1: Confirm Strict Mode is Active

Check your entry point (index.js, main.jsx, main.tsx):

<StrictMode>
  <App />
</StrictMode>
Enter fullscreen mode Exit fullscreen mode

If StrictMode isn't there, double invocations indicate a real bug—likely in your routing or parent component logic.

Step 2: Add Logging to Understand the Flow

useEffect(() => {
  console.log('Effect SETUP for:', userId);

  return () => {
    console.log('Effect CLEANUP for:', userId);
  };
}, [userId]);
Enter fullscreen mode Exit fullscreen mode

In Strict Mode you'll see:

Effect SETUP for: 123
Effect CLEANUP for: 123
Effect SETUP for: 123
Enter fullscreen mode Exit fullscreen mode

This confirms the mount → unmount → remount cycle.

Step 3: Test in Production Mode

Run a production build locally:

npm run build
npm run preview  # or equivalent for your setup
Enter fullscreen mode Exit fullscreen mode

In production, Strict Mode is disabled. If the double invocation persists, you have an actual bug.

Step 4: Check Your Dependencies Array

A common cause of unexpected re-runs is unstable dependencies:

// ❌ Creates new object every render → effect runs every render
useEffect(() => {
  // ...
}, [{ some: 'object' }]);

// ✅ Stable primitive
useEffect(() => {
  // ...
}, [userId]);
Enter fullscreen mode Exit fullscreen mode

Objects, arrays, and functions defined inline during render are new on every render, causing effects to re-run.


Performance Implications: Should You Worry?

A natural concern: "Won't this double execution slow down my app?"

In development: Yes, marginally. But development speed inherently differs from production. The safety benefits outweigh the milliseconds.

In production: Strict Mode is completely stripped. There's zero performance impact. Your effect runs exactly once per mount/dependency change.

The React team regularly reminds developers: do not optimize for development performance. The dev build includes many checks, warnings, and intentional slowdowns that don't exist in production.


Best Practices for Resilient Effects

Let's consolidate everything into a set of actionable best practices:

1. Always Return a Cleanup Function

Make it a habit. Even if you think you don't need cleanup:

useEffect(() => {
  // Setup logic

  return () => {
    // Cleanup logic (even if empty comment)
    // This forces you to think about what needs cleaning up
  };
}, [deps]);
Enter fullscreen mode Exit fullscreen mode

2. Use AbortController for All Fetch Calls

This pattern should be second nature:

useEffect(() => {
  const controller = new AbortController();

  fetch(url, { signal: controller.signal })
    .then(/* ... */)
    .catch(err => {
      if (err.name !== 'AbortError') throw err;
    });

  return () => controller.abort();
}, [url]);
Enter fullscreen mode Exit fullscreen mode

3. Store Mutable References in Refs

When effects need mutable state that persists across cleanup cycles:

const socketRef = useRef(null);

useEffect(() => {
  socketRef.current = new WebSocket(url);

  return () => socketRef.current?.close();
}, [url]);
Enter fullscreen mode Exit fullscreen mode

4. Extract Custom Hooks for Reusable Patterns

Centralize correct behavior:

function useFetch(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    const controller = new AbortController();
    setLoading(true);

    fetch(url, { signal: controller.signal })
      .then(res => {
        if (!res.ok) throw new Error(res.statusText);
        return res.json();
      })
      .then(setData)
      .catch(err => {
        if (err.name !== 'AbortError') setError(err);
      })
      .finally(() => setLoading(false));

    return () => controller.abort();
  }, [url]);

  return { data, loading, error };
}
Enter fullscreen mode Exit fullscreen mode

5. Consider React Query or SWR

For complex data fetching, these libraries handle caching, deduplication, and cleanup automatically:

// Using React Query
import { useQuery } from '@tanstack/react-query';

function UserProfile({ userId }) {
  const { data: user, isLoading, error } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json()),
  });

  if (isLoading) return <Loading />;
  if (error) return <Error error={error} />;
  return <div>{user.name}</div>;
}
Enter fullscreen mode Exit fullscreen mode

React Query deduplicates requests automatically—even with Strict Mode's double mount, you'll see only one network request.


Edge Cases and Advanced Scenarios

Race Conditions in Sequential Updates

Consider a user rapidly switching between profiles:

// User clicks: Profile A → Profile B → Profile C (rapid clicks)
Enter fullscreen mode Exit fullscreen mode

Without proper cleanup, you might see:

  1. Request A starts
  2. Request B starts
  3. Request C starts
  4. Request A completes → sets state to User A
  5. Request C completes → sets state to User C
  6. Request B completes → sets state to User B ← Wrong!

The user expects to see User C, but User B was the last to complete.

AbortController fixes this: when the user clicks to Profile B, Request A is aborted. When they click to Profile C, Request B is aborted. Only Request C completes.

Effects That Should Run on Mount Only

Sometimes you genuinely need "mount-only" logic that shouldn't re-run. Use a ref carefully:

const initialized = useRef(false);

useEffect(() => {
  // Run setup that should only happen once
  initializeComplexLibrary();

  if (!initialized.current) {
    initialized.current = true;
    // One-time setup like registering the app with a backend
    registerAppInstance();
  }

  return () => {
    // But always clean up
    cleanupComplexLibrary();
  };
}, []);
Enter fullscreen mode Exit fullscreen mode

Note: the cleanup still runs every time. The ref only guards the "one-time" registration logic.

Synchronizing with External Systems

When your effect syncs state with something outside React (DOM, external API, browser storage):

useEffect(() => {
  // Read external state on mount
  const savedTheme = localStorage.getItem('theme');
  if (savedTheme) setTheme(savedTheme);

  // No cleanup needed for reading
  // But if you set up listeners, clean them up

  const handler = (e) => {
    if (e.key === 'theme') setTheme(e.newValue);
  };
  window.addEventListener('storage', handler);

  return () => window.removeEventListener('storage', handler);
}, []);
Enter fullscreen mode Exit fullscreen mode

Disabling Strict Mode: Should You?

You can remove StrictMode:

// Before
<StrictMode>
  <App />
</StrictMode>

// After
<App />
Enter fullscreen mode Exit fullscreen mode

Should you? Almost never. Here's why:

  1. You're hiding bugs, not fixing them. Those bugs will manifest in production.
  2. You lose future-proofing. Next React features may rely on resilient effects.
  3. It's a code smell. If you need to disable Strict Mode, your effects likely have structural problems.

The only legitimate reason to temporarily disable Strict Mode is when debugging to isolate whether an issue is Strict Mode-related or a separate bug.


Conclusion: Embrace the Double Invocation

React 19's Strict Mode isn't working against you—it's training you to write better code. Every time you encounter a double invocation, ask yourself:

  1. Does my effect have a cleanup function?
  2. Is my cleanup function actually cleaning up what the effect set up?
  3. Would my effect work correctly if it ran 3 times? 10 times? 100 times?

If the answer to all three is "yes," your effect is production-ready.

The patterns in this guide—AbortController for fetches, cleanup functions for subscriptions, refs for mutable state—aren't just Strict Mode workarounds. They're the correct way to write effects, period. Strict Mode just makes their importance undeniable.

Next time your effect fires twice, don't reach for a workaround. Thank Strict Mode for catching a bug before your users did, and write a cleanup function. Your future self—debugging a production issue at 2 AM—will thank you.


💡 Note: This article was originally published on the Pockit Blog.

Check out Pockit.tools for 50+ free developer utilities (JSON Formatter, Diff Checker, etc.) that run 100% locally in your browser.

Top comments (0)