Why React Strict Mode Double-Fires Your Analytics Events (and How to Stop It)
React Strict Mode doubling your analytics? Here's the fix.
Three weeks ago a developer in a Discord I lurk in posted a screenshot of their analytics dashboard. Two thousand pageviews in development. Their site had three pages. They'd refreshed maybe fifty times total while debugging. The math didn't add up. If you're setting up cookieless analytics or tracking SPA route changes, this React Strict Mode double-firing issue will bite you hard.
Someone replied "oh that's Strict Mode" and the conversation moved on like this was common knowledge. But the original poster had been debugging for two hours before asking. And honestly? I've been there. The first time I saw doubled event counts in a React 18 project, I assumed my tracking code was broken. I rewrote the useEffect three times before googling the right combination of words. (Not my proudest debugging session.)
Here's what's happening: React 18's Strict Mode intentionally double-invokes your effects in development. It mounts your component, unmounts it, and mounts it again — all before the first paint. Every useEffect runs twice. Every analytics call inside those effects fires twice. Your dev dashboard shows numbers that look broken because they are. Twice as broken as reality, specifically.
This post explains why React does this, why your analytics code triggers it, and the idempotent patterns that fix it without disabling the safety checks Strict Mode provides.
What React Strict Mode Actually Does
Strict Mode is a development-only wrapper that helps catch bugs before they hit production. In React 18, it gained a new behavior: it simulates your component unmounting and remounting immediately after the first mount. The React team calls this "remounting with preserved state" — your component gets a full mount-unmount-remount cycle while keeping the same state values.
Here's the timeline:
- Component mounts
useEffectruns (your analytics fires here — first time)- Component unmounts (cleanup function runs if you have one)
- Component mounts again
useEffectruns again (your analytics fires here — second time)
All of this happens before you see anything on screen. The component appears once. Your analytics recorded it twice.
// This fires twice in Strict Mode development
useEffect(() => {
window.ja?.('pageview', { url: location.pathname })
}, [])
In production? This fires once. The double-invocation only exists in development. But developers see the doubled numbers, assume something is wrong with their code, and start "fixing" things that aren't broken — or worse, they ship workarounds that cause actual bugs in production.
Why React Does This (and Why You Shouldn't Disable It)
The React team added this behavior to catch a specific class of bugs: effects that don't clean up properly. If your effect subscribes to something and never unsubscribes, you'll get double subscriptions in Strict Mode. If your effect starts a timer and never clears it, you'll see two timers running. If your effect adds an event listener and forgets to remove it, the remount makes that obvious.
The idea is that if your code works correctly when remounted, it'll work correctly in all the edge cases that cause remounts in production — Suspense boundaries resolving, concurrent rendering, offscreen components being shown again.
So no, don't just yank <React.StrictMode> out of your app. That's hiding problems, not fixing them. I've seen teams do this and regret it six months later when their memory leaks finally got bad enough to notice. (Ask me how I know. Actually, don't.)
The fix is making your analytics effects idempotent — they should produce the same result whether they run once or twice.
The Idempotent Pattern for Analytics
The core insight: if we track whether the effect already fired, we can skip the second invocation. Here's the pattern:
// components/Analytics.tsx
'use client'
import { useEffect, useRef } from 'react'
import { usePathname, useSearchParams } from 'next/navigation'
export function Analytics() {
const pathname = usePathname()
const searchParams = useSearchParams()
const tracked = useRef(false)
const lastUrl = useRef('')
useEffect(() => {
const url = pathname + (searchParams?.toString() ? `?${searchParams}` : '')
// Skip if we already tracked this exact URL
if (tracked.current && lastUrl.current === url) {
return
}
tracked.current = true
lastUrl.current = url
window.ja?.('pageview', { url })
// Reset on unmount so remounting works correctly
return () => {
tracked.current = false
}
}, [pathname, searchParams])
return null
}
The tracked ref starts false. First mount sets it to true and fires the pageview. When Strict Mode unmounts the component, the cleanup function resets tracked to false. When it remounts... wait, shouldn't it fire again?
Here's the key: we also track lastUrl. On remount, tracked is false but lastUrl still equals the current URL. The check passes, we set tracked to true again, and... we fire the pageview. Hmm.
Actually, let me revise that. I overcomplicated it. The simpler pattern works better:
'use client'
import { useEffect, useRef } from 'react'
import { usePathname } from 'next/navigation'
export function Analytics() {
const pathname = usePathname()
const hasFired = useRef<string | null>(null)
useEffect(() => {
// Skip if we already fired for this URL
if (hasFired.current === pathname) {
return
}
hasFired.current = pathname
window.ja?.('pageview', { url: pathname })
}, [pathname])
return null
}
No cleanup function. The ref persists across the mount-unmount-mount cycle because refs aren't reset on unmount. First mount: hasFired.current is null, condition fails, we fire and store the pathname. Second mount (Strict Mode remount): hasFired.current equals pathname, condition passes, we skip. Navigation happens: pathname changes, condition fails because they don't match, we fire.
This pattern works in dev and prod. In dev it prevents doubles. In prod it does nothing because there's only one mount — but it also doesn't break anything.
Handling Custom Events
Pageviews are predictable — they fire on mount and on route change. Custom events are messier. A "Sign Up" button click shouldn't be deduplicated the same way — if a user clicks twice, that's two events, and you probably want to know about it.
The rule: deduplicate mount-triggered effects, not user-triggered events.
// This is CORRECT — user clicks should fire every time
function SignupButton() {
function handleClick() {
window.ja?.('event', 'signup_clicked', { source: 'hero' })
// ... rest of signup logic
}
return <button onClick={handleClick}>Sign Up</button>
}
// This is WRONG — user clicks should NOT be deduplicated
function SignupButton() {
const hasFired = useRef(false)
function handleClick() {
if (hasFired.current) return // DON'T DO THIS
hasFired.current = true
window.ja?.('event', 'signup_clicked', { source: 'hero' })
}
return <button onClick={handleClick}>Sign Up</button>
}
Click handlers aren't affected by Strict Mode's double-invocation because they're not inside useEffect. They fire when the user clicks, period. The only analytics code you need to protect is the stuff that runs automatically on mount or state change.
The useRef vs useState Trap
I've seen developers try to fix this with useState instead of useRef:
// Don't do this
const [hasFired, setHasFired] = useState(false)
useEffect(() => {
if (hasFired) return
setHasFired(true) // This triggers a re-render
window.ja?.('pageview', { url: pathname })
}, [pathname, hasFired])
This causes an unnecessary re-render. Worse, it's the kind of subtle performance hit that doesn't show up until you've got 50 of these scattered across your app. The component renders, the effect fires and calls setHasFired(true), which schedules another render, which runs the effect again (but exits early because hasFired is now true). You've prevented the double analytics call but added a pointless render cycle.
Refs don't trigger re-renders. They're the right tool here. Reach for refs when you need mutable values that don't affect the UI.
Framework-Specific Notes
Next.js App Router: The pattern above works directly. Mount it in your root layout wrapped in Suspense (because useSearchParams needs it). If you're also using our SPA route change tracking, the approaches are compatible — just make sure you're not running both the idempotent pageview component AND a History API hook, or you'll get doubles from a different source.
Create React App: Same pattern. CRA enables Strict Mode by default in index.tsx. The ref-based deduplication works identically.
Vite with React plugin: Strict Mode depends on your template. The default React template includes it. If you're not seeing doubles in dev, check if Strict Mode is actually enabled — it might not be, which means you're missing out on the bug detection it provides.
Remix: Remix handles a lot of analytics wiring through its loader/action patterns. If you're firing client-side events, the ref pattern still applies. For error tracking alongside analytics, see our unified observability guide.
When Doubles Are Actually Bugs
Sometimes doubled events are a real bug, not a Strict Mode artifact. If you're seeing doubles in production (not just dev), check these:
-
Multiple script tags. Did your bundler include the analytics script twice? Check your production HTML source. I've seen this happen with misconfigured Next.js
<Script>components. Embarrassingly common. -
Multiple component instances. Is your Analytics component mounted more than once? If it's in a layout that wraps multiple routes, and each route also imports it, you've got duplicates.
-
React 18 concurrent features. Suspense boundaries can cause components to mount multiple times as data resolves. The ref pattern handles this too — but if you're not using it, concurrent rendering will double your events just like Strict Mode does.
-
History API hooks AND framework router hooks. We covered this in the SPA tracking post. If you're hooking both
history.pushStateAND your framework's navigation events, every route change fires two pageviews. Pick one.
For teams doing ad campaign attribution, doubled events mess up your conversion tracking. If JustEmails or ClickzProtect is reading your analytics events to attribute conversions, inflated counts will skew your data. Worth fixing properly rather than eyeballing an adjustment. Teams using VeloCalls for call tracking see similar issues when analytics events don't match call conversion data.
Testing Your Fix
The easiest way to verify: open React DevTools, go to Components, find the Settings gear, and enable "Highlight updates when components render." Then navigate through your app in development. You should see:
- Your Analytics component highlight twice on initial load (that's Strict Mode)
- But your network panel should show only ONE request per pageview
- Navigation to a new route should show ONE request
If you're seeing two requests per pageview in dev, your deduplication isn't working. If you're seeing zero requests, your condition is too aggressive and you're blocking everything.
Also test that navigation still works:
- Load the page (one pageview)
- Click an internal link (one pageview)
- Click another link (one pageview)
- Hit back button (one pageview)
- Refresh (one pageview)
Five pageviews total. Not ten. Not three. If your numbers don't match, read the effect logic again — there's usually an off-by-one in the condition or a missing dependency. Been there, done that, got the gray hairs.
The Session Replay Angle
If you're using JustAnalytics session replay, the double-mount behavior shows up there too. In dev, you might see recordings that start, stop, and restart within the same session. That's Strict Mode unmounting and remounting. In production, recordings are continuous.
The session replay SDK already handles this internally — it doesn't start a new session on every mount. But if you're wiring up your own session boundaries or firing session-start events manually, apply the same ref pattern. You don't want your dashboard showing twice as many sessions as you actually had.
Frequently Asked Questions
Does React Strict Mode double-fire effects in production?
No. Strict Mode's double-invocation only happens in development builds. Production builds run effects exactly once. The problem is that developers see inflated numbers in dev, assume their tracking is broken, and either waste hours debugging or ship 'fixes' that break production behavior.
Should I just remove React.StrictMode to fix this?
No. Strict Mode exists to catch bugs — specifically effects that don't clean up properly and components that aren't resilient to re-mounting. Removing it hides problems you'll hit later in production. The right fix is making your effects idempotent, not disabling the tool that exposes the issue.
Will this affect my production analytics numbers?
Not if you implement it correctly. The double-fire only happens in development. But if you've been debugging in dev and 'fixing' things based on those doubled numbers, you might have introduced bugs that do affect production. Check your prod data against dev — if they're wildly different, that's your clue.
Does this apply to other analytics tools like Mixpanel or Amplitude?
Yes. Any analytics call inside a useEffect will fire twice in Strict Mode. Mixpanel, Amplitude, Segment, PostHog, Heap — same problem, same fix. The idempotent pattern in this post works regardless of which SDK you're calling.
Try JustAnalytics
All-in-one observability in one under-5KB script: cookieless analytics + error tracking + APM + session replay + uptime + structured logs. Replaces GA4 + Sentry + Datadog + Pingdom + LogRocket. Free tier (100K events/mo), Pro $49/month ($39 annual).
Author at JustAnalytics.