React StrictMode permanently disables your init guard in development

In short

React StrictMode mounts every component, runs cleanup, then remounts. A module-level `if (initialised) return` guard is set on the first mount, survives the cleanup because it lives outside the component, and blocks the second mount from ever re-attaching its listeners. The feature is then dead for the whole dev session while production works fine.

A turnstile locked after a single rotation, with a figure stuck behind it and a used ticket on the floor.
Affects
Development only — StrictMode double-mount is not in production
Trigger
Init state stored outside the component (module scope, or a ref never reset)
Symptom
Listeners attached once, then detached and never re-attached
Tell
Works on a production build, dead under `next dev`

My visitor tracking did nothing in development. Not intermittently — never. No errors, no failed requests, no warnings. On a production build it worked perfectly. That gap between the two is the entire fingerprint of this bug, and once you know it you can spot it in about ten seconds.

#The sequence

StrictMode in development deliberately mounts a component, runs its effect cleanup, and mounts it again. The point is to surface effects that are not safe to run twice. It does that job well. It also punishes a very common pattern.

  1. First mount: the guard is unset, so initialisation runs and listeners are attached.
  2. The guard is set to true — but it lives in module scope, not component state.
  3. StrictMode runs cleanup: the listeners are removed.
  4. Second mount: the guard is still true, because unmounting a component does not reset a module-level variable.
  5. Initialisation is skipped. The listeners are never re-attached. Nothing runs for the rest of the session.
let initialised = false;           // module scope — survives unmount

export default function VisitorPing() {
  useEffect(() => {
    if (initialised) return;         // ← blocks the StrictMode remount
    initialised = true;

    const onScroll = () => { /* … */ };
    window.addEventListener("scroll", onScroll);
    return () => window.removeEventListener("scroll", onScroll);
  }, []);

  return null;
}
The shape that breaks. The guard outlives the component that set it.

The cleanup is correct. The guard is correct in isolation. The combination is what fails, and only under a mount/cleanup/remount cycle you never see in production.

#Why it hides so well

This compounds with any subsystem that already fails quietly. In my case the write path returns false on failure rather than throwing, so a partial failure and a success are indistinguishable from the calling side. Two silent layers stacked on top of each other take a long time to see through.

#Telling it apart from a real bug

One check settles it. Build for production and run the built output — not next dev.

npm run build && npm start

If the feature works there and not in development, you are looking at a StrictMode interaction, not a bug in your logic. Do not spend the afternoon reading the logic.

#Two fixes

1. Move the guard inside the component

A useRef is re-created on the second mount, so the remount initialises normally. This is the right fix when the guard exists to protect against a double *run*, which is precisely what StrictMode is testing for.

const initialised = useRef(false);

useEffect(() => {
  if (initialised.current) return;
  initialised.current = true;
  // …
}, []);

2. Make the effect idempotent and drop the guard

Better still, write the effect so running it twice is harmless, and delete the guard entirely. addEventListener with a stable handler reference is already idempotent; a fetch that writes a row is not, and needs deduplication on the server side rather than a client-side flag that development will defeat.

#The rule I now follow

Any state that decides whether an effect has already run must live at the same lifetime as the effect. Module scope outlives the component; a ref does not. When those two lifetimes disagree, development and production disagree with them — and the environment that lies to you is the one you spend all day in.

Questions this answers

Why do my event listeners not work under React StrictMode in development?

StrictMode mounts, cleans up, and remounts each component. If your initialisation guard is stored outside the component — in module scope — it stays true through the cleanup, so the second mount skips initialisation and never re-attaches the listeners the cleanup removed. The listeners stay detached for the rest of the session.

How do I know if StrictMode is the cause and not my code?

Run a production build and test against that. StrictMode's double-mount only happens in development, so a feature that works in the built output and fails under the dev server is almost certainly hitting this rather than a logic bug.

Should I disable StrictMode to fix it?

No. The double-mount is a deliberate test for effects that are not safe to run twice, and disabling it hides the symptom while leaving the fragility in place. Move the guard into a useRef so it resets with the component, or make the effect idempotent and remove the guard entirely.

See also

  • Learnings The skill map — knowledge domains, competencies and credentials
  • MIGI Agent Fleet A 46-agent autonomous fleet with 500+ automated eval checks

Let's Build
What Comes Next.

Open to meaningful collaborations, AI-native systems, product strategy, and future-focused conversations.

“Human instinct. AI amplification.
Systemic execution.”

Suman Debnath

·

Brand Marketing Leader & AI Product Builder

© 2026

This site records visit data — pages viewed, time and scroll depth, device, your IP address and the approximate location and network provider derived from it — and sends it to me privately. It also runs Google Analytics and Vercel Analytics. Full detail and how to opt out.

This site, its code and its content are © 2026 Suman Debnath. All rights reserved — none of it is open source, and copying it needs permission first. Terms of use.