Fetching data in React: the case of lost Promises
Nadia Makarevich
Posted on November 16, 2022
Originally published at https://www.developerway.com. The website has more articles like this đ
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 FIRST
fetch('/some-url') // create promise here
.then(() => { // wait for Promise to be done
// log stuff after the promise is done
console.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 data
const url = `/some-url/${id}`;
useEffect(() => {
fetch(url)
.then((r) => r.json())
.then((r) => {
// save data from fetch request to state
setData(r);
});
}, [url]);
// render data
return (
<>
<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 mounted -
Page
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 again -
fetch
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
again -
useEffect
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 âacitveâ.
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 ref
const ref = useRef(id);
useEffect(() => {
// update ref value with the latest id
ref.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 id
if (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 ref
const ref = useRef(id);
useEffect(() => {
// update ref value with the latest url
ref.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 url
if (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 useEffect
useEffect(() => {
// "cleanup" function - function that is returned in useEffect
return () => {
// 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 useEffect call 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 run
let isActive = true;
// do fetch here
return () => {
// local variable from above
isActive = 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 state
if (isActive) {
setData(r);
}
});
return () => {
// set this closure to not active before next re-render
isActive = 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 here
const controller = new AbortController();
// pass controller as signal to fetch
fetch(url, { signal: controller.signal })
.then((r) => r.json())
.then((r) => {
setData(r);
});
return () => {
// abort the request here
controller.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 AbortController
if (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.
Originally published at https://www.developerway.com. The website has more articles like this đ
Subscribe to the newsletter, connect on LinkedIn or follow on Twitter to get notified as soon as the next article comes out.
Posted on November 16, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.