Errors and fixes
useEffect runs twice: that is StrictMode, not a bug
In development React mounts, unmounts and mounts each component to expose effects that cannot clean up. Write the cleanup, do not switch StrictMode off.
Key points
- The double run happens in development only, and only with `
`: its job is to expose effects that are not idempotent. - A correct effect cancels the request or the subscription in its cleanup.
- If the double run breaks the feature, the same failure would appear in production on a real remount.
Short answer: it is not a bug. Under <StrictMode>, React 18 and 19 run every effect twice in development, mount, cleanup, mount, so you can see the effects that cannot undo themselves.
The mechanism: a test, not a double run
StrictMode asks React to try the full cycle: mount, run the effect, run the cleanup, remount and run again. The goal is to prove the effect can go back. An effect that survives the cycle is an effect that will behave when the component really remounts.
The behaviour exists in development only, and only with the wrapper in place. In production the cycle is one. Watch one detail: Fast Refresh also remounts components while you work, so two requests may come from a save rather than from StrictMode.
mount → effect → cleanup → effect
# in produzione
mount → effect
The double pass is not a performance penalty, it is a test. An effect that cannot survive the cycle will break on a real remount, meaning a route change or a different key.
The fix: symmetric cleanup
useEffect(() => {
const controller = new AbortController();
fetch("/api/orders", { signal: controller.signal })
.then((r) => r.json())
.then(setOrders)
.catch((e) => {
if (e.name !== "AbortError") throw e;
});
return () => controller.abort();
}, []);
Without abort the first response can arrive after the second and overwrite the state with stale data. That is the classic defect that looks like a double fetch in development and becomes wrong data in production, hard to reproduce because it depends on the order the responses arrive in.
Subscriptions
useEffect(() => {
const mq = window.matchMedia("(min-width: 768px)");
const onChange = () => setWide(mq.matches);
onChange();
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}, []);
The rule is general: every addEventListener, setInterval, subscribe or observe needs its own removal. An empty cleanup means the double run is doing its job and showing you a leak.
Diagnosis: count the pairs, not the runs
A counter in useRef plus two logs tell you within seconds whether the cycle is healthy. The absolute number matters little; the sequence matters.
const runs = useRef(0);
useEffect(() => {
runs.current += 1;
console.log(`effect #${runs.current}`, userId);
return () => console.log(`cleanup #${runs.current}`);
}, [userId]);
Two effects with a cleanup between them are the expected signal. Ten effects in a row mean a dependency that changes identity on every render. A single effect with no cleanup means the component never unmounted, and the problem lies elsewhere.
When the usual fix makes it worse
The first shortcut is to switch to empty dependencies to stop the loop. The effect stops restarting and keeps reading the values from the first render: you ask for one user's data and receive the previous user's. The error moves from “two requests” to “someone else's data”, which in production is far worse.
The second is a guard built on a module-level flag, in the shape of “if I already ran, exit”. It holds until the component remounts: the variable survives the remount, the effect never restarts and the subscription stays closed for good. A useRef guard is less harmful, but in the same StrictMode it skips the second run after the cleanup has already closed everything.
One limit to budget for: the test has side effects
The trial cycle really runs your code, twice. When the effect sends a POST, the server receives two writes in development. StrictMode cannot know whether the operation is idempotent: the double run is a destructive test by design. The practical consequence is that the effect must be safe to repeat, or the send belongs in an event handler with an idempotency key.
How to prevent it
- Write the cleanup together with the effect, before you test.
- Use
AbortControllerfor every request tied to a component's lifecycle. - Do not disable StrictMode to make a test pass: fix the effect.
- For regressions, mount, unmount and remount in the test, then assert that no listener is left.
Where to go next
In the catalogue: React components, dashboard with data and icons. The guides: accessibility checklist and what a UI library weighs. The comparison: copy-paste or headless.
Frequently asked questions
Does the double run send two requests to the server?
Yes, in development. That is why the cleanup must cancel the first one with AbortController: without it the first response can overwrite the second with stale data.
Can I remove StrictMode?
You can, but you lose the check React offers precisely to catch fragile effects. Remove the wrapper and the leak stays, except now nothing shows it to you.
Does it happen in production too?
Not because of StrictMode. It happens when the component really remounts: a route change, a different key, a suspension, or a Fast Refresh during development.
Does this apply to useLayoutEffect too?
Yes, the rule is identical: every subscription or request needs a symmetric cleanup. The moment of the cycle changes, not the contract.
How do I check that the cleanup works?
Log inside the cleanup, then mount the component twice. The line must appear before the second effect, with no warning about a state update after unmount.