If you upgraded to React 18 and started seeing your useEffect body run twice in development, you’re not imagining it — and it’s intentional.
What’s happening
React 18 mounts, unmounts, then remounts every component once in development (Strict Mode) to surface side-effect bugs. That means effects run, get cleaned up, then run again.
useEffect(() => {
const id = setInterval(() => console.log("tick"), 1000);
return () => clearInterval(id); // runs between the two invocations
}, []);
Why it’s a good thing
- It forces you to write effects that are safe to run, clean up, and re-run — the real behavior in production across fast refresh and concurrent features.
- Most “double run” bugs are actually missing cleanup bugs that would bite you later.
How to fix the common symptoms
If a fetch fires twice and you see duplicate requests, make sure you cancel the in-flight request:
useEffect(() => {
const controller = new AbortController();
fetch("/api/user", { signal: controller.signal })
.then((r) => r.json())
.then(setUser)
.catch((e) => { if (e.name !== "AbortError") throw e; });
return () => controller.abort();
}, []);
Don’t suppress it
Avoid hacks like a module-level “hasRun” flag. That hides the symptom and breaks under concurrent rendering. The double-invoke only happens in development — production builds run effects exactly once.