Table of Contents
- What is a Promise
- Promises and race conditions
- Race condition reasons
- Fixing race conditions: force re-mounting
- Fixing race conditions: drop incorrect result
- Fixing race conditions: drop all previous results
- Fixing race conditions: cancel all previous requests
- Does Async/await change anything?
There is more
Nadia Makarevich
Fetching data in React: the case of lost Promises
Deep dive into Promises and data fetching in React.
What is a Promise, how Promises can cause race conditions, what are the reasons for them to do that, a few ways to prevent race conditions from happening.
How would you like to be a bad guy? Some evil genius of frontend development who can write seemingly innocent code, which will pass all the tests and code reviews, but will cause the actual app to behave weird. Some random data pops up here and there, search results don’t match the actual query, and navigating between tabs makes you think that your app is drunk.
Or maybe instead you’d rather be the hero 🦸🏻♀️️ that stops all of this from happening?
Regardless of the moral path you choose, if all of this sounds interesting, then it’s time to continue the conversation about the fundamentals of data fetching in React. This time let’s talk about Promises: what they are, how they can cause race conditions when fetching data, and how to avoid them.
And if you haven’t read the previous “data fetching fundamentals” article yet, here it is: How to fetch data in React with performance in mind.
What is a Promise
Before jumping into implementing evil (or heroic) masterplans, let’s remember what is a Promise and why we need them.
Essentially Promise is a… promise 🙂 When javascript executes the code, it usually does it synchronously: step by step. A Promise is one of the very few available to us ways to execute something asynchronously. With Promises, we can just trigger a task and move on to the next step immediately, without waiting for the task to be done. And the task promises that it will notify us when it’s completed. And it does! It’s very trustworthy.
One of the most important and widely used Promise situations is data fetching. Doesn’t matter whether it’s the actual fetch
call or some abstraction on top of it like axios, the Promise behavior is the same.
From the code perspective, it’s just this:
console.log('first step'); // will log FIRSTfetch('/some-url') // create promise here.then(() => {// wait for Promise to be done// log stuff after the promise is doneconsole.log('second step'); // will log THIRD (if successful)}).catch(() => {console.log('something bad happened'); // will log THIRD (if error happens)});console.log('third step'); // will log SECOND
Basically, the flow is: create a promise fetch('/some-url')
and do something when the result is available in .then
or handle the error in .catch
. That’s it. There are a few more details to know of course to completely master promises, you can read them in the docs. But the core of that flow is enough to understand the rest of the article.
Promises and race conditions
One of the most fun parts of promises is the race conditions they can cause. Check this out: I implemented a very simple app for this article.
It has tabs column on the left, navigating between tabs sends a fetch request, and the data from the request is rendered on the right. Try to quickly navigate between tabs in it and enjoy the show: the content is blinking, data appears seemingly at random, and the whole thing is just mind-boggling.
How did this happen? Let’s take a look at the implementation.
We have two components there. One is the root App
component, it manages the state of the active “page”, and renders the navigation buttons and the actual Page
component.
const App = () => {const [page, setPage] = useState("1");return (<><!-- left column buttons --><button onClick={() => setPage("1")}>Issue 1</button><button onClick={() => setPage("2")}>Issue 2</button><!-- the actual content --><Page id={page} /></div>);};
Page
component accepts id
of the active page as a prop, sends a fetch request to get the data, and then renders it. Simplified implementation (without the loading state) looks like this:
const Page = ({ id }: { id: string }) => {const [data, setData] = useState({});// pass id to fetch relevant dataconst url = `/some-url/${id}`;useEffect(() => {fetch(url).then((r) => r.json()).then((r) => {// save data from fetch request to statesetData(r);});}, [url]);// render datareturn (<><h2>{data.title}</h2><p>{data.description}</p></>);};
With id
we determine the url
from where to fetch data from. Then we’re sending the fetch
request in useEffect
, and storing the result data in state - everything is pretty standard. So where does the race condition and that weird behavior come from?
Race condition reasons
It all comes down to two things: the nature of Promises and React lifecycle.
From the lifecycle perspective what happens is this:
App
component is mountedPage
component is mounted with the default prop value “1”useEffect
inPage
component kicks in for the first time
Then the nature of Promises comes into effect: fetch
within useEffect
is a promise, asynchronous operation. It sends the actual request, and then React just moves on with its life without waiting for the result. After ~2 seconds the request is done, .then
of the promise kicks in, within it we call setData to preserve the data in the state, the Page
component is updated with the new data, and we see it on the screen.
If after everything is rendered and done I click on the navigation button, we’ll have this flow of events:
App
component changes its state to another page- State change triggers re-render of
App
component - Because of that,
Page
component will re-render as well (here is a helpful guide with more links if you’re not sure why: React re-renders guide: everything, all at once) useEffect
inPage
component has a dependency onid
,id
has changed,useEffect
is triggered againfetch
inuseEffect
will be triggered with the newid
, after ~2 seconds setData will be called again,Page
component updates and we’ll see the new data on the screen
But what will happen if I click on a navigation button and the id
changes while the first fetch is in progress and hasn’t finished yet? Really cool thing!
App
component will trigger re-render ofPage
againuseEffect
will be triggered again (id has changed!)fetch
will be triggered again, and React will continue with its business as usual- then the first fetch will finish. It still has the reference to
setData
of the exact samePage
component (remember - it just updated, so the component is still the same) setData
after the first fetch will be triggered,Page
component will update itself with the data from the first fetch- then the second fetch finishes. It was still there, hanging out in the background, as any promise would do. That one also has the reference to exactly the same setData of the same
Page
component, it will be triggered,Page
will again update itself, only this time with the data from the second fetch.
Boom 💥, race condition! After navigating to the new page we see the flash of content: the content from the first finished fetch is rendered, then it’s replaced by the content from the second finished fetch.
This effect is even more interesting if the second fetch finishes before the first fetch. Then we’ll see first the correct content of the next page, and then it will be replaced by the incorrect content of the previous page.
Check out the example below. Wait until everything is loaded for the first time, then navigate to the second page, and quickly navigate back to the first page.
Okay, the evil deed is done, the code is innocent, but the app is broken. Now what? How to solve it?
Fixing race conditions: force re-mounting
The first one is not even a solution per se, it’s more of an explanation of why those race conditions don’t actually happen that often, and why we usually don’t see them during regular page navigation.
Imagine instead of the implementation above we'd have something like this:
const App = () => {const [page, setPage] = useState('issue');return (<>{page === 'issue' && <Issue />}{page === 'about' && <About />}</>);};
No passing down props, Issue
and About
components have their own unique urls from which they fetch the data. And the data fetching happens in useEffect
hook, exactly the same as before:
const About = () => {const [about, setAbout] = useState();useEffect(() => {fetch("/some-url-for-about-page").then((r) => r.json()).then((r) => setAbout(r));}, []);...}
This time there is no race condition while navigating. Navigate as many times and as fast as you want: the app behaves normally.
Why? 🤔
The answer is here: {page === ‘issue' && <Issue />}
. Issue
and About
page are not re-rendered when page
value changes, they re-mounted. When value changes from issue
to about
, the Issue
component unmounts itself, and About
component is mounted on its place.
What is happening from the fetching perspective is this:
- the
App
component renders first, mounts theIssue
component, data fetching there kicks in - when I navigate to the next page while the fetch is still in progress, the
App
component unmountsIssue
page and mountsAbout
component instead, it kicks off its own data fetching
And when React unmounts a component, it means it's gone. Gone completely, disappears from the screen, no one has access to it, everything that was happening within including its state is lost. Compare it with the previous code, where we wrote <Page id={page} />
. This Page
component was never unmounted, we were just re-using it and its state when navigating.
So back to the unmounting situation. When the Issue
's fetch request finishes while I’m on About
page, the .then
callback of the Issue
component will try to call its setIssue
state. But the component is gone, from React perspective it doesn’t exist anymore. So the promise will just die out, and the data it got will just disappear into the void.
By the way, do you remember that scary warning “Can’t perform a React state update on an unmounted component”? It used to appear in exactly those situations: when an asynchronous operation like data fetching finishes after the component is gone already. “Used to”, since it’s gone as well. Was removed quite recently: Remove the warning for setState on unmounted components by gaearon · Pull Request #22114 · facebook/react. Quite an interesting read on the reasons for those who like to have all the details.
Anyway. In theory, this behavior can be applied to solve the race condition in the original app: all we need is to force Page component to re-mount on navigation. We can use “key” attribute for this:
<Page id={page} key={page} />
⚠️ This is not a solution I would recommend for the race conditions problem, too many caveats: performance might suffer, unexpected bugs with focus and state, unexpected triggering of useEffect
down the render tree. It's more like sweeping the problem under the rug. There are better ways to deal with race conditions (see below). But it can be a tool in your arsenal in certain cases if used carefully.
If you never used key
before, not sure why all those bugs will happen, and want to understand how it works, this article might be useful: React key attribute: best practices for performant lists
Fixing race conditions: drop incorrect result
A much more gentle way to solve race conditions, instead of nuking the entire Page
component from existence, is just to make sure that the result coming in .then
callback matches the id that is currently “active”.
If the result returns the “id” that was used to generate the url
, we can just compare them. And if they don’t match - ignore them. The trick here is to escape React lifecycle and locally scoped data in functions and get access to the “latest” id inside all iterations of useEffect
, even the “stale” ones. React ref is perfect for this:
const Page = ({ id }) => {// create refconst ref = useRef(id);useEffect(() => {// update ref value with the latest idref.current = id;fetch(`/some-data-url/${id}`).then((r) => r.json()).then((r) => {// compare the latest id with the result// only update state if the result actually belongs to that idif (ref.current === r.id) {setData(r);}});}, [id]);};
Your results don’t return anything that identifies them reliably? No problem, we can just compare url
instead:
const Page = ({ id }) => {// create refconst ref = useRef(id);useEffect(() => {// update ref value with the latest urlref.current = url;fetch(`/some-data-url/${id}`).then((result) => {// compare the latest url with the result's url// only update state if the result actually belongs to that urlif (result.url === ref.current) {result.json().then((r) => {setData(r);});}});}, [url]);};
Fixing race conditions: drop all previous results
Don’t like the previous solution or think that using ref for something like this is weird? No problem, there is another way. useEffect
has something that is called “cleanup” function, where we can clean up stuff like subscriptions. Or in our case active fetch requests.
The syntax for it looks like this:
// normal useEffectuseEffect(() => {// "cleanup" function - function that is returned in useEffectreturn () => {// clean something up here};// dependency - useEffect will be triggered every time url has changed}, [url]);
The cleanup function is run after a component is unmounted, or before every re-render with changed dependencies. So the order of operations during re-render will look like this:
- url changes
- “cleanup” function is triggered
- actual content of
useEffect
is triggered
This, and the nature of javascript’s functions and closures allows us to do this:
useEffect(() => {// local variable for useEffect's runlet isActive = true;// do fetch herereturn () => {// local variable from aboveisActive = false;};}, [url]);
We’re introducing a local boolean variable isActive
and setting it to true
on useEffect
run and to false
on cleanup. The function in useEffect
is re-created on every re-render, so the isActive
for the latest useEffect
run will always reset to true
. But! The “cleanup” function, which runs before it, still has access to the scope of the previous function, and it will reset it to false
. This is how javascript closures work.
And fetch
Promise, although async, still exists only within that closure and has access only to the local variables of the useEffect
run that started it. So when we check the isActive
boolean in .then
callback, only the latest run, that one that hasn’t been cleaned up yet, will have the variable set to true
. So all we need now is just check whether we’re in the active closure, and if yes - set state. If no - do nothing, the data will just again disappear into the void.
useEffect(() => {// set this closure to "active"let isActive = true;fetch(`/some-data-url/${id}`).then((r) => r.json()).then((r) => {// if the closure is active - update stateif (isActive) {setData(r);}});return () => {// set this closure to not active before next re-renderisActive = false;};}, [id]);
Fixing race conditions: cancel all previous requests
Feeling that dealing with javascript closures in the context of React lifecycle makes your brain explode? I’m with you, sometimes thinking about all of this gives me a headache. But not to worry, there is another option to solve the problem.
Instead of cleaning up or comparing results, we can just cancel all the previous requests. If they never finish, the state update with obsolete data will never happen, and the problem just won’t exist. We can use AbortController for this.
It’s as simple as creating AbortController
in useEffect
and calling .abort()
in the cleanup function.
useEffect(() => {// create controller hereconst controller = new AbortController();// pass controller as signal to fetchfetch(url, { signal: controller.signal }).then((r) => r.json()).then((r) => {setData(r);});return () => {// abort the request herecontroller.abort();};}, [url]);
So on every re-render the request in progress will be cancelled and the new one will be the only one allowed to resolve and set state.
Aborting a request in progress will make the promise reject, so you’d want to catch errors to get rid of the scary warnings in the console. But handling Promise rejections properly is a good idea regardless of AbortController, so it’s something you’d want to do with any strategy. Rejecting because of AbortController will give a specific type of error, so it will be easy to exclude it from regular error handling.
fetch(url, { signal: controller.signal }).then((r) => r.json()).then((r) => {setData(r);}).catch((error) => {// error because of AbortControllerif (error.name === 'AbortError') {// do nothing} else {// do something, it's a real error!}});
Does Async/await change anything?
Nope, not really. Async/await is just a nicer way to write exactly the same promises. It just turns them into “synchronous” functions from the execution flow perspective but doesn’t change their asynchronous nature. Instead of:
fetch('/some-url').then((r) => r.json()).then((r) => setData(r));
we’d write:
const response = await fetch('/some-url');const result = await response.json();setData(result);
Exactly the same app implemented with async/await instead of “traditional” promises will have exactly the same race condition. Check it out in the codesandbox. And all the solutions and reasons from the above apply, just syntax will be slightly different.
That’s enough promises for one article I think. Hope you found it useful and never will introduce a race condition into your code. Or if someone tries to do it, you’ll catch them in the act.
And check out the previous article on data fetching in React, if you haven’t yet: How to fetch data in React with performance in mind. It has more fundamentals and core concepts that are essential to know when dealing with data fetching on the frontend.