Prevent React from triggering useEffect twice
On March 29, 2022, the React team released version 18.0 of their library, and with the update came this infamous addition to Strict Mode:
New Strict Mode Behaviors
[…] React 18 introduces a new development-only check to Strict Mode. This new check will automatically unmount and remount every component, whenever a component mounts for the first time, […].
— https://reactjs.org/blog/2022/03/29/react-v18.html#new-strict-mode-behaviors
What followed were lots of confused developers seeking help. And rightly so, because even if the feature comes from good intentions, the behavior is definitely unexpected and surprising. And to be honest, it is actually pretty horrible software design for such a popular library. It behaves differently in development mode than it does with a production build, behind the scenes they also mess with the console logs, so that those don’t appear twice. A perfect recipe for desperate developers to pull their hair out and go insane trying to solve this issue. I don’t even want to think about how many developer hours were already wasted on this quirk.
What is actually happening?
When you have the Strict Mode enabled (which you should), React performs additional runtime checks in the development environment to make sure your app is safe and sound. As of version 18 this also includes every component getting mounted (causing useEffect
to be executed), unmounted, and finally mounted again (causing useEffect
to be executed a second time). React does this to check that your effects are resilient to remounting, so that that certain behind-the-scene optimizations can be performed safely. But what if your components simply are not resilient?
The official recommendations are mostly useless
Alright, but what can we do? The React documentation has a few recommendations for us:
[…] you can deploy your app to a staging environment (which runs in production mode) or temporarily opt out of Strict Mode and its development-only remounting checks.
— https://beta.reactjs.org/learn/you-might-not-need-an-effect#sending-a-post-request
Wow, so they’re essentially saying that you can’t have a fully functional development environment if your app doesn’t work with the double useEffect
invocation. At this point, I’d like to get off topic and continue with a massive rant, but I’ll keep it professional, contain my anger and move on.
In the same document, they also show an example of performing an HTTP request when a component is mounted. To be precise, a POST request is sent to an analytics endpoint when a page is loaded. Obviously, you do not want the event to be reported twice, as the page is only opened a single time. But the conclusion in the documentation is, I shit you not, the complete opposite:
In development,
logVisit
will be called twice for every URL, so you might be tempted to try to work around it. We recommend to keep this code as is. Like with earlier examples, there is no user-visible behavior difference between running it once and running it twice. From a practical point of view,logVisit
should not do anything in development because you don’t want the logs from the development machines to skew the production metrics.
— https://beta.reactjs.org/learn/you-might-not-need-an-effect#sending-a-post-request
The audacity to craft an artificial example that actually needs no fixes under their artificial constraints is beyond me. And so they move on without offering an actual solution. So I guess we actually did end up in a bit of a rant here, sorry. I mean, yes, they are right. Just live with the double trigger behavior if you possibly can. It’s there to uncover flaws in your code. But what if it’s not acceptable in your situation? Let’s throw out the documentation and solve the problem.
A simple way around
In my case, I was integrating Single sign-on (SSO) from Twitter into a website. When a user accepts my app permissions on Twitter, they are redirected back to my website with some special tokens in the query parameters (e.g. /sso/twitter?oauth_token=xxx
). When this page loads, all I have to do is send the token to my backend and wait for it to return a user session. Afterwards I simply redirect into the user dashboard. But guess what happens if the backend request is triggered twice? Well, both requests will fail with a 500 error as Twitter rejects my attempt to obtain two OAuth access tokens for the same user simultaneously.
The naive approach that brought me down this rabbit hole writing a blog post looked like this:
useEffect(() => {
postIntegrationsTwitterLogin(oauth.token).then((session) => {
setSession(session)
router.replace("/dashboard")
})
}, [])
And here is a simple workaround leveraging useState
:
const initialized = useRef(false)
useEffect(() => {
if (!initialized.current) {
initialized.current = true
postIntegrationsTwitterLogin(oauth.token).then((session) => {
setSession(session)
router.replace("/dashboard")
})
}
}, [])
And last but not least, a re-usable hook to encapsulate the mess we just made:
import type { EffectCallback } from "react"
import { useEffect, useRef } from "react"
export function useOnMountUnsafe(effect: EffectCallback) {
const initialized = useRef(false)
useEffect(() => {
if (!initialized.current) {
initialized.current = true
effect()
}
}, [])
}