Initializing Enclave...

How to Fix React StrictMode useEffect Running Twice in Development (React 18 Double Mount)

Threat/Impact Level: HIGH | Downtime Risk: MEDIUM (dev parity breaks, silent prod bugs) | Time to Fix: 10–30 mins

TL;DR

  • What broke: React 18 StrictMode mounts → unmounts → remounts every component in development, so useEffect fires twice. Your API call fires twice, your WebSocket opens twice, your analytics event fires twice.
  • How to fix it: Return a cleanup function from every useEffect. Use AbortController for fetch calls. The double-mount is a feature, not a bug — it surfaces missing cleanup that will bite you in production.
  • Fast path: Use our Client-Side Sandbox below to auto-refactor this — paste your component and it generates the corrected cleanup pattern without sending your code to a third-party server.

The Incident (What Does the Error Mean?)

There is no thrown exception. That is what makes this insidious. Your console looks like this:

[API] Fetching user profile for id=42
[API] Fetching user profile for id=42   ← duplicate
[WebSocket] Connection opened
[WebSocket] Connection opened             ← duplicate
[Analytics] page_view fired
[Analytics] page_view fired               ← duplicate

React 18 introduced Strict Mode double-invoking of the full mount lifecycle in development (React.StrictMode in your root). The sequence is:

  1. Component mounts → useEffect runs
  2. React simulates an unmount (calls cleanup if it exists)
  3. React remountsuseEffect runs again

This is documented behavior. The intent is to force you to write resilient effects that survive remounting — because in production, React's upcoming concurrent features (Offscreen, Fast Refresh, Server Components) will do exactly this. If your effect has no cleanup, you have a latent production bug that StrictMode is exposing.


The Attack Vector / Blast Radius

This is not just a noisy dev log. The real damage:

1. Duplicate mutations hit your backend. A POST /api/order inside a useEffect with no cleanup fires twice on mount. In dev this is annoying. If this pattern ships and a concurrent React feature triggers a remount in production, you have a double-charge or duplicate-order bug.

2. Race conditions become invisible. Two in-flight fetches for the same resource. Whichever resolves last wins and sets state. In dev with fast localhost APIs, the second always wins cleanly. On a slow 3G mobile connection in production, the first can win and overwrite fresher data.

3. WebSocket/EventSource connection leaks. Without a cleanup that calls socket.close(), each remount leaks a connection. In production, navigation patterns that trigger remounts accumulate open sockets until the browser tab crashes or the server drops the client.

4. Analytics/telemetry inflation. page_view fires twice per navigation. Your funnel metrics are 2×. Decisions get made on corrupted data.

5. setState on unmounted component warnings cascade. The first effect's async callback resolves after the simulated unmount and tries to call setState, flooding the console and masking real warnings.


How to Fix It (The Solution)

Basic Fix — AbortController for Fetch

The most common pattern: a fetch inside useEffect with no cleanup.

 useEffect(() => {
-  fetch(`/api/users/${userId}`)
-    .then(res => res.json())
-    .then(data => setUser(data));
- }, [userId]);
+  const controller = new AbortController();
+
+  fetch(`/api/users/${userId}`, { signal: controller.signal })
+    .then(res => res.json())
+    .then(data => setUser(data))
+    .catch(err => {
+      if (err.name === 'AbortError') return; // expected on cleanup
+      console.error('Fetch failed:', err);
+    });
+
+  return () => controller.abort();
+ }, [userId]);

When StrictMode triggers the simulated unmount, controller.abort() fires, cancels the in-flight request, and the AbortError is swallowed. The second mount starts a fresh request. One request in flight at a time. Always.


Enterprise Best Practice — Full Pattern Coverage

WebSocket / EventSource cleanup:

 useEffect(() => {
-  const socket = new WebSocket('wss://api.example.com/stream');
-  socket.onmessage = (e) => dispatch(streamEvent(JSON.parse(e.data)));
- }, []);
+  const socket = new WebSocket('wss://api.example.com/stream');
+  socket.onmessage = (e) => dispatch(streamEvent(JSON.parse(e.data)));
+
+  return () => {
+    socket.close(1000, 'Component unmounted');
+  };
+ }, []);

setInterval / setTimeout cleanup:

 useEffect(() => {
-  const id = setInterval(() => setTick(t => t + 1), 1000);
- }, []);
+  const id = setInterval(() => setTick(t => t + 1), 1000);
+  return () => clearInterval(id);
+ }, []);

Third-party library subscription cleanup:

 useEffect(() => {
-  const unsubscribe = store.subscribe(() => setState(store.getState()));
- }, []);
+  const unsubscribe = store.subscribe(() => setState(store.getState()));
+  return () => unsubscribe();
+ }, []);

Analytics — fire-once guard using useRef:

+const hasFired = useRef(false);
+
 useEffect(() => {
-  analytics.track('page_view', { page: '/checkout' });
- }, []);
+  if (hasFired.current) return;
+  hasFired.current = true;
+  analytics.track('page_view', { page: '/checkout' });
+ }, []);

⚠️ Do NOT disable StrictMode to silence the double-mount. That is the wrong fix. You are papering over a cleanup debt that will manifest as production bugs when React's concurrent scheduler remounts components for legitimate reasons.


💡 Tired of pasting proprietary configs into ChatGPT? Generic AI tools log your company's ARNs, DB strings, and private keys. StackEngine is a zero-backend, pure Client-Side WASM utility. Drop your failing config into the sandbox above. We redact your secrets locally in the browser and auto-generate the refactored code using your own API key.


Prevention in CI/CD

This class of bug should never reach a PR review. Automate it out.

1. ESLint exhaustive-deps + react-hooks/rules-of-hooks

Install eslint-plugin-react-hooks and enforce it in your lint pipeline:

{
  "rules": {
    "react-hooks/rules-of-hooks": "error",
    "react-hooks/exhaustive-deps": "warn"
  }
}

This won't catch missing cleanups directly, but it catches the dependency array mistakes that lead to stale closures masking the double-fire.

2. Custom ESLint rule or eslint-plugin-react-compiler (React 19+)

The upcoming React Compiler enforces pure render functions and idempotent effects. Integrate it in CI as a blocking lint step:

npx react-compiler-healthcheck --strict

3. Playwright / Cypress integration test: assert no duplicate network calls

// Playwright example
const requests = [];
page.on('request', req => {
  if (req.url().includes('/api/users')) requests.push(req.url());
});
await page.goto('/profile');
await page.waitForLoadState('networkidle');
expect(requests.filter(u => u.includes('/api/users'))).toHaveLength(1); // strict: exactly one

Run this in CI against the development build (StrictMode enabled). A duplicate request fails the pipeline before merge.

4. Bundle analysis gate — flag useEffect without return

Use jscodeshift or a custom AST rule in your pre-commit hook to flag useEffect calls where the callback does not return a function. This is a blunt instrument but catches the obvious cases:

# In .husky/pre-commit
npx jscodeshift --dry --print -t ./codemods/require-useeffect-cleanup.js src/

5. Storybook + @storybook/addon-interactions

Storybook runs in StrictMode by default since v7. Your interaction tests will expose double-fire side effects during component development, before they ever reach a feature branch.

Related Diagnostics

"Part of the Performance Utility Matrix."

View all 219 Performance Tools →