Why useEffect Runs Twice in Development

React Strict Mode mounts every component twice in development to check for missing cleanup. That double useEffect run is intentional, not a bug.

June 18, 20265 min read2 / 3

I built a small advice app. It loaded, fetched a piece of advice from an API, and displayed it. The count of how many pieces I had read started at 2 instead of 1.

I spent a while staring at the code. The fetch was called once. The state update was called once. Everything looked correct.

It was not a bug in my code. It was React telling me something.

What Actually Happened

In development, React runs your effects twice on every mount. It mounts the component, runs the effect, unmounts the component, and mounts it again. This is the behavior of React Strict Mode.

TSX
useEffect(() => { getAdvice(); // This runs twice on first mount in development }, []);

If getAdvice increments a counter, the counter starts at 2. If it fetches data, two requests go out. If it subscribes to something, two subscriptions are registered.

Not a bug. A test.

Why React Does This

React 18 introduced this behavior to surface a specific class of bug: effects that do not clean up after themselves.

Here is the mental model. An effect that is safe to run twice is an effect that has proper cleanup. If React mounts → unmounts → remounts and the end result is identical to just mounting once, your effect is correct. If the result is different -- two subscriptions instead of one, a counter that starts at 2, a fetch that fires twice -- you have a cleanup problem that was going to cause bugs eventually.

Strict Mode just makes that problem visible immediately in development, instead of letting it appear as a subtle bug in production after some edge case unmounts your component.

Timeline showing Strict Mode lifecycle: Mount, Effect runs, Unmount, Mount again, Effect runs again. Below: two panels comparing missing cleanup (two fetch requests race, counter = 2) versus with cleanup using AbortController (one clean request, counter = 1). ExpandTimeline showing Strict Mode lifecycle: Mount, Effect runs, Unmount, Mount again, Effect runs again. Below: two panels comparing missing cleanup (two fetch requests race, counter = 2) versus with cleanup using AbortController (one clean request, counter = 1).

The Effects That Break

The pattern is always the same: the effect sets something up, but the cleanup does not tear it down.

Subscriptions:

TSX
useEffect(() => { const unsubscribe = store.subscribe(handleChange); // No return -- subscriptions pile up on every mount }, []);

After two mounts, you have two active subscriptions. Both fire on every store update. Your handler runs twice.

Timers:

TSX
useEffect(() => { const id = setInterval(() => setCount(c => c + 1), 1000); // No clearInterval -- two timers run after Strict Mode double-mount }, []);

Two timers means the counter increments twice per second instead of once.

Fetch requests:

TSX
useEffect(() => { fetch('/api/advice') .then(res => res.json()) .then(data => setAdvice(data.slip.advice)); // No AbortController -- two requests race to update state }, []);

Two requests fire. Whichever resolves last wins. On a slow network this causes a flicker. With a counter, it causes a double-increment.

The Fix: Write the Cleanup

The solution is not to remove Strict Mode. The solution is to write cleanup functions.

TSX
useEffect(() => { const controller = new AbortController(); fetch('/api/advice', { signal: controller.signal }) .then(res => res.json()) .then(data => setAdvice(data.slip.advice)) .catch(err => { if (err.name !== 'AbortError') throw err; }); return () => controller.abort(); // Cleanup: cancel the in-flight request }, []);

Now when React unmounts between the two mounts, the cleanup runs. The first request is aborted. The second mount fires a fresh request. The end result is exactly one successful fetch -- identical to what you would get without Strict Mode.

For subscriptions and timers:

TSX
useEffect(() => { const unsubscribe = store.subscribe(handleChange); return () => unsubscribe(); // Cleanup: remove the subscription }, []); useEffect(() => { const id = setInterval(() => setCount(c => c + 1), 1000); return () => clearInterval(id); // Cleanup: stop the timer }, []);

An effect that properly cleans up survives Strict Mode's double-mount unchanged. That is the signal React is looking for.

Development Only

This behavior does not happen in production.

React Strict Mode only runs in development. It is a development-only safety check, not a runtime behavior change. When you build for production, effects run exactly once on mount. There is nothing to worry about for your deployed app.

The double-mount exists exclusively to catch bugs while you are writing code -- before they reach your users.

The Rule to Carry Forward

Every effect that sets something up should tear it down.

TSX
useEffect(() => { // Setup const something = setupSomething(); return () => { // Teardown -- always teardownSomething(something); }; }, [deps]);

If your effect does something that cannot be undone -- a network request that already fired, a counter that already incremented -- you need an abort mechanism or an ignore flag. If it can be undone cleanly, return the teardown.

Effects that survive double-mounting are effects that survive the real world.

Components unmount for real reasons: navigation, conditional rendering, parent re-renders. The same bugs Strict Mode surfaces in development will appear in production under those conditions. Strict Mode just makes them reproducible on every render so you catch them early.

The Essentials

  1. React Strict Mode mounts every component twice in development. This is intentional -- it surfaces effects that do not clean up after themselves.
  2. An effect is correct if running it twice produces the same result as running it once. If the result differs, you are missing a cleanup function.
  3. This is development-only. Production builds run effects once. Strict Mode is a safety check, not a performance regression.

Further Reading and Watching