You upgraded to React 18. Your effects fire twice. Your fetch runs twice, your analytics event doubles, your WebSocket connects twice.
The folklore says "React mounts, unmounts, and mounts again". Close, and wrong in the way that matters. React does not remount anything. The double-invoke is documented, it is dev-only, and it is a test you are failing.
The reconciler runs a rehearsal
After the first commit, in development, the reconciler walks the fibers flagged for it. It runs your effect cleanups, then your effects again: layout cleanups, passive cleanups, layout setups, passive setups, in that order. The reconciler source calls the function commitDoubleInvokeEffectsInDEV. No second render pass, no fiber deletion. State, refs and DOM all survive. This is a rehearsal of "effects destroyed and re-created on a mounted component".
Why rehearse that? The team is building an Offscreen API. React detaches a tree on tab away, on a virtualized list, on back navigation. Later it reattaches the tree with state preserved and runs the effects again. The production twins of the rehearsal already sit in the same source file, disappearLayoutEffects and reappearLayoutEffects. Suspense uses them today. The v18 changelog says it plainly, "make Suspense remount layout effects when content reappears".
So the question StrictMode asks is precise. Can your effect setup and cleanup cycle run twice with the same deps and leave the world identical?
The failing patterns
The working group thread enumerates the shapes. They reduce to three.
// 1. Effect without cleanup. The zombie.
useEffect(() => {
socket.connect()
}, [])Two setups, two sockets, zero teardowns. The fix is symmetry.
useEffect(() => {
socket.connect()
return () => socket.disconnect()
}, [])// 2. The uncancelled fetch. The race.
useEffect(() => {
fetch(`/api/user/${id}`).then((r) => r.json()).then(setUser)
}, [id])The double-invoke makes the race visible, but the race was always there. Change id fast and the slow response overwrites the fresh one. AbortController closes both holes. Swallow the abort, and rethrow a real failure.
useEffect(() => {
const controller = new AbortController()
fetch(`/api/user/${id}`, { signal: controller.signal })
.then((r) => r.json())
.then(setUser)
.catch((e) => {
if (e.name !== 'AbortError') throw e
})
return () => controller.abort()
}, [id])// 3. The once-guard. The framework fight.
const didInit = useRef(false)
useEffect(() => {
if (didInit.current) return
didInit.current = true
initAnalytics()
}, [])Here the no-remount mechanics bite. The double-invoke never resets refs, so didInit.current is still true on the second run. The guard passes the rehearsal. Feels like a win.
Now play it forward to Offscreen. Hide the tree, reattach it, and the effects run again with the refs preserved. Your guard turns reconnection into a permanent no-op. The socket never comes back. StrictMode rehearsed the exact case your guard breaks, and the guard cheated the rehearsal. App-level one-time init belongs at module scope, next to createRoot, where the problem dissolves.
The double-invoke is a free idempotence fuzzer running on every save. Effects that survive it survive fast refresh, survive id changing mid-flight, and survive the Offscreen future. Do not silence the alarm. Fix the kitchen.