- 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
loadingstate is set totrue, then thefetchis started. - Because of Strict Mode, the cleanup function is called immediately after the Effect function is finished. Note that the
fetchmay not be finished yet. - React calls the Effect function again. In this case, the
loadingstate is set totrue. However, because theabortController.abort()was called in the cleanup function, the previousfetchis now aborted, an error is thrown, and it is caught by the catch block. And since theloadingstate is set to false in this catch block, theloadingstate's value is immediately set tofalseagain. Thus theloadingstate is nevertruein 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!
Image
