- Published on
React Strict Mode and Race Condition
I actually caught a bug thanks to Strict Mode
- Authors
- Name
- Nico Prananta
- Follow me on Bluesky
One of the main reasons React introduced Strict Mode is to help us find hard-to-notice bugs, like race conditions. One of the ways it does this is by re-running Effects twice in development mode.
When I created the aborting fetch demo in the previous post, I stumbled upon a race condition bug which I had neither noticed nor understood at the beginning. This was the buggy code:
function MyComponent() {
// in production, don't use multiple states like this
const [loading, setLoading] = useState(false)
const [data, setData] = useState<any>(null)
const [error, setError] = useState<any>(null)
const abortControllerRef = useRef<AbortController | null>(null)
useEffect(() => {
const abortController = new AbortController()
abortControllerRef.current = abortController
const fetchData = async () => {
// reset the states
setLoading(true)
setError(null)
setData(null)
// @ts-ignore don't know why TS doesn't recognize any
const combinedSignal = AbortSignal.any([
abortController.signal,
AbortSignal.timeout(1_000 * 5), // 5 seconds timeout
])
const url = window.location.protocol + '//' + window.location.host
const fetchURL = `${url}/experiments/fetch-abort-demo/api`
try {
const response = await fetch(fetchURL, {
signal: combinedSignal,
})
const data = await response.json()
setData(data)
setLoading(false)
setError(null)
} catch (error: any) {
setLoading(false)
if (error.name === 'TimeoutError') {
setError('Timeout: It took more than 5 seconds to get the result!')
}
if (error.name === 'AbortError') {
setError(`Fetch canceled by user`)
}
}
}
fetchData()
return () => {
abortController.abort()
abortControllerRef.current = null
}
}, [])
return (
<div>
{data && !loading && !error ? <p>Data: {data.message}</p> : null}
{error && !loading && !data ? <p>Error: {error}</p> : null}
{loading ? (
<button
className="cursor-default rounded-md border border-black px-4 py-2"
onClick={() => abortControllerRef.current?.abort()}
>
Loading. Will timeout in 5 seconds. Click to cancel now.
</button>
) : null}
</div>
)
}
In the above component, the loading
state is never set to true
during development. Can you guess why?
At first, I thought even though the useEffect
is run twice, setLoading(true)
should still have been called before the fetch
is executed.
So, this is where Strict Mode exposes the subtle bug in the code. What happened was like this:
- React calls the Effect function. In this case, the
loading
state is set totrue
, then thefetch
is started. - Because of Strict Mode, the cleanup function is called immediately after the Effect function is finished. Note that the
fetch
may not be finished yet. - React calls the Effect function again. In this case, the
loading
state is set totrue
. However, because theabortController.abort()
was called in the cleanup function, the previousfetch
is now aborted, an error is thrown, and it is caught by the catch block. And since theloading
state is set to false in this catch block, theloading
state's value is immediately set tofalse
again. Thus theloading
state is nevertrue
in development mode.
This code is buggy in a very subtle way because there's a possibility that it updates the current state of the component based on an operation that is no longer validβthe previously aborted fetch
. The fix to this bug is pretty simple.
We just need to have an indicator that the fetch
was aborted because of the cleanup function:
function MyComponent() {
// in production, don't use multiple states like this
const [loading, setLoading] = useState(false)
const [data, setData] = useState<any>(null)
const [error, setError] = useState<any>(null)
const abortControllerRef = useRef<AbortController | null>(null)
useEffect(() => {
let isCleandUp = false // this is the indicator
const abortController = new AbortController()
abortControllerRef.current = abortController
const fetchData = async () => {
// reset the states
setLoading(true)
setError(null)
setData(null)
// @ts-ignore don't know why TS doesn't recognize any
const combinedSignal = AbortSignal.any([
abortController.signal,
AbortSignal.timeout(1_000 * 5), // 5 seconds timeout
])
const url = window.location.protocol + '//' + window.location.host
const fetchURL = `${url}/experiments/fetch-abort-demo/api`
try {
const response = await fetch(fetchURL, {
signal: combinedSignal,
})
const data = await response.json()
if (!isCleandUp) {
setData(data)
setLoading(false)
setError(null)
}
} catch (error: any) {
if (!isCleandUp) {
setLoading(false)
if (error.name === 'TimeoutError') {
setError('Timeout: It took more than 5 seconds to get the result!')
}
if (error.name === 'AbortError') {
setError(`Fetch canceled by user`)
}
}
}
}
fetchData()
return () => {
isCleandUp = true
abortController.abort()
abortControllerRef.current = null
}
}, [])
return (
<div>
{data && !loading && !error ? <p>Data: {data.message}</p> : null}
{error && !loading && !data ? <p>Error: {error}</p> : null}
{loading ? (
<button
className="cursor-default rounded-md border border-black px-4 py-2"
onClick={() => abortControllerRef.current?.abort()}
>
Loading. Will timeout in 5 seconds. Click to cancel now.
</button>
) : null}
</div>
)
}
Using the isCleanedUp
variable, we make sure that all state updates inside the Effect happen only when the cleanup function is not called.
By the way, you should use Tanstack Query instead of fetching data inside useEffect
like this.
Are you working in a team environment and your pull request process slows your team down? Then you have to grab a copy of my book, Pull Request Best Practices!