Fetching data in React: the case of lost Promises

Nadia Makarevich
13 min readNov 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 in Page 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 in Page component has a dependency on id, id has changed, useEffect is triggered again
  • fetch in useEffect will be triggered with the new id, 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 of Page 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 same Page 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 the Issue component, data fetching there kicks in
  • when I navigate to the next page while the fetch is still in progress, the App component unmounts Issue page and mounts About 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} />

See example in codesandbox

⚠️ 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]);
}

See example in codesandbox

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]);
}

See example in codesandbox

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 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 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]);

See example in codesandbox

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!
}
});

See example in codesandbox

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.

--

--

Responses (1)