It is pretty common for React's useEffect
to introduce Race Condition Bugs. This can happen any time you have asynchronous code inside of React.useEffect
.
What is a Race Condition Bug?
A race condition can happen when there are two asynchronous processes that will both be updating the same value. In this scenario, it's the last process to complete that ends up updating the value.
This may not be what we want. We might want the last process to be started to update the value.
An example of this is a component that fetches data and then re-renders and re-fetches data.
Example Race Condition Component
This is an example of a component that could have a Race Condition Bug.
import { useEffect, useState } from "react";
import { getPerson } from "./api";
export const Race = ({ id }) => {
const [person, setPerson] = useState(null);
useEffect(() => {
setPerson(null);
getPerson(id).then((person) => {
setPerson(person);
};
}, [id]);
return person ? `${id} = ${person.name}` : null;
}
At first glance, there doesn't seem to be anything wrong with this code and that's what can make this bug so dangerous.
useEffect
will fire every time id
changes and call getPerson
. If getPerson
is started and the id
changes, a second call to getPerson
will start.
If the first call finishes before the second call, then it will overwrite person
with data from the first call, causing a bug in our application.
AbortController
When using fetch
, you could use an AbortController
to manually abort the first request.
NOTE: Later on, we'll find a simpler way to do this. This code is just for education purposes.
import { useEffect, useRef, useState } from "react";
import { getPerson } from "./api";
export const Race = ({ id }) => {
const [data, setData] = useState(null);
const abortRef = useRef(null);
useEffect(() => {
setData(null);
if (abortRef.current != null) {
abortRef.current.abort();
}
abortRef.current = new AbortController();
fetch(`/api/${id}`, { signal: abortRef.current.signal })
.then((response) => {
abortRef.current = null;
return response;
})
.then((response) => response.json())
.then(setData);
}, [id]);
return data;
}
Canceling the Previous Request
The AbortController
isn't always an option for us since some asynchronous code doesn't work with an AbortController
. So we still need a way to cancel the previous async call.
This is possible by setting a cancelled
flag inside of useEffect
. We can set this to true
when the id
changes using the unmount
feature of useEffect
.
NOTE: Later on, we'll find a simpler way to do this. This code is just for education purposes.
import { useEffect, useState } from "react";
import { getPerson } from "./api";
export const Race = ({ id }) => {
const [person, setPerson] = useState(null);
useEffect(() => {
let cancelled = false;
setPerson(null);
getPerson(id).then((person) => {
if (cancelled) return; // only proceed if NOT cancelled
setPerson(person);
};
return () => {
cancelled = true; // cancel if `id` changes
};
}, [id]);
return person ? `${id} = ${person.name}` : null;
}
Use React Query
I would not recommend handling the aborting or cancelling manually inside of each component. Instead, you should wrap that functionality inside a React Hook. Fortunately there is a library that has already done that for us.
I would recommend using the react-query library. This library will prevent race condition bugs as well as provide some other nice things like caching, retries, etc.
I also like the way react-query simplifies the code.
import { useQuery } from "react-query";
import { getPerson } from "./api";
export const Race = ({ id }) => {
const { isLoading, error, data } = useQuery(
["person", id],
(key, id) => getPerson(id)
);
if (isLoading) return "Loading...";
if (error) return `ERROR: ${error.toString()}`;
return `${id} = ${data.name}`;
}
The first argument to react-query is the cache key and the 2nd is a function that will be called when there is no cache or the cache is stale or invalid.
Summary
Race condition bugs can occur when there is an asynchronous call inside of React.useEffect
and React.useEffect
fires again.
When using fetch
, you could abort the request. APromise
can be cancelled. But I would recommend against manually writing that code for each component and instead use a library like react-query.
Subscribe to my newsletter on joel.net
Find me on Twitter @joelnet or YouTube JoelCodes
Cheers 🍻