•5 min read

React useEffect Runs Twice: What StrictMode Is Actually Telling You

JavaScript code on a laptop screen during debugging

Last month our signup analytics showed exactly double the signups we actually had. The culprit was a useEffect that fired its tracking call twice on every page load in development, a teammate "fixed" it by deleting <StrictMode> from the root, and we shipped for three days before anyone noticed the real problem: a different effect with a broken dependency array was firing on every render in production. Removing StrictMode hid the symptom and kept the bug.

If your effects run twice, here is what is actually going on and the fixes that hold up.

Why the effect fires twice

Since React 18, StrictMode mounts every component, runs its effects, runs the cleanup, then runs the effects again. On purpose. The sequence in development looks like this:

// 1. component mounts, effect runs
// 2. React simulates an unmount, cleanup runs
// 3. React remounts, effect runs again

It only happens in development builds. In production your effects run once. The point is to expose effects that assume they run exactly once, because those are the same effects that break for real when a component unmounts and remounts, or when the user navigates away mid-request.

So the double run is not the bug. It is the test. The bug is whatever breaks when the test runs.

Fetches: abort in the cleanup

The most common case is data fetching. Without a cleanup, StrictMode starts two requests and the stale one can win the race:

useEffect(() => {
  fetch('/api/users')
    .then((r) => r.json())
    .then(setUsers);
}, []);

Abort the request when the effect tears down and only one fetch ever completes:

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

  fetch('/api/users', { signal: controller.signal })
    .then((r) => r.json())
    .then(setUsers)
    .catch((err) => {
      if (err.name !== 'AbortError') setError(err);
    });

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

StrictMode runs: fetch starts, cleanup aborts it, effect runs again, fresh fetch completes. One request in flight at a time. I covered the messier corners of this in my AbortController deep dive, including why aborted rejections land where they do.

Subscriptions and timers: the same move

Anything that registers itself needs to unregister itself. WebSocket, event listener, store subscription, interval. All the same shape:

useEffect(() => {
  const ws = new WebSocket('wss://example.com/feed');
  ws.onmessage = (e) => setMessages((prev) => [...prev, e.data]);
  return () => ws.close();
}, []);

Without that ws.close(), StrictMode leaves you with two open connections in dev, and your cleanup hygiene is broken everywhere else too. Honestly, this is the check I run first on any codebase I inherit: open a page, watch the Network tab, count the duplicate sockets and subscriptions.

What about things that genuinely run once?

Analytics is the classic case. You do not want to abort a page-view event, and you do not want to fire it twice either. A ref guard works:

const tracked = useRef(false);

useEffect(() => {
  if (tracked.current) return;
  tracked.current = true;
  analytics.page('dashboard');
}, []);

One warning. Do not reach for a module-level let sent = false instead. A ref belongs to one component instance, so a remount gets a fresh one. A module-level flag does not reset, which means it can keep swallowing runs after a hot reload, and tracking quietly stops until you do a full reload. I have paid for that lesson twice.

When it really is a bug: unstable dependencies

Here is the part the StrictMode articles skip. If effects run twice in a production build, StrictMode is not the cause, because it does nothing there. The usual suspect is an object or function in the dependency array that gets a new reference every render:

useEffect(() => {
  fetchData(config);
}, [config]); // new object every render, effect runs every render

React compares dependencies by reference. A new object means "changed", every time. Pass primitives instead:

useEffect(() => {
  fetchData({ page, limit });
}, [page, limit]);

That was exactly our double-signup bug in production. Dev double-fired because of StrictMode, prod fired constantly because of the dependency array. Two different causes wearing the same costume.

So before you delete StrictMode from the root: build for production, run the app, and check whether the effect still misbehaves. Once in prod means the effect needs a cleanup. Twice in prod means the dependencies are lying to you. The reference-comparison problem shows up in re-renders too, and it is the same mental model in both places.

StrictMode caught a real bug for us. Keep it on, write effects that survive running twice, and the double-fire disappears on its own.