In development, Strict Mode mounts each component, unmounts it, and mounts it again, so an effect runs, cleans up, and runs once more. The fix is an effect whose cleanup fully undoes its setup.
useEffect(() => {
const id = setInterval(tick, 1000);
return () => clearInterval(id);
}, []);
With that cleanup the double run leaves one interval. Without it, two tick side by side, and the “bug” is the missing cleanup, which would also have bitten on any real remount.
The patterns that break
-
A fetch with no cancel. Two requests go out; the first to resolve wins, possibly with stale data. Abort in the cleanup:
useEffect(() => { const controller = new AbortController(); fetch(url, { signal: controller.signal }).then(setData).catch(() => {}); return () => controller.abort(); }, [url]); -
A subscription with no unsubscribe. Two listeners, every event handled twice.
-
A counter or analytics call in the effect body. It fires twice in dev. Move one-time app work outside components, or accept it, because production runs once.
What this is not
It isn’t a render loop, and it isn’t a dependency problem. Production builds don’t double-invoke; React.StrictMode only does this in development, and only for effects, reducers and render functions, to expose exactly the missing cleanups above.
Gotchas
- A ref flag like
if (ranOnce.current) returnhides the symptom and keeps the leak. Write the cleanup instead. - The same double call happens to
useLayoutEffectand touseStateinitializers, which is why an initializer must be pure. - Removing
<StrictMode>frommain.tsxmakes the warning go away and the leak stay.