React Native

Suspense and React Query make data fetching easy

Data fetching in a React / React Native app is hard. Spinners, errors, refetching… As the number of queries grows, it can become pretty dreadful to maintain. But not anymore thanks to Suspense and React Query! This is the last time you'll ever add a spinner and an error screen in your app.

Let's dive in!

Overview

Officially released in 2022 with React 18, Suspense is a game changer for asynchronous React. It makes it very easy to display a loading state for your component, when you have to wait for some data on the server — or any asynchronous response really.

React Query is an excellent library that focuses on synchronizing your app’s state with your server data. It comes bundled with automatic caching, refetching, error handling… It’s our go-to solution for data fetching in all our React Native projects, and one of our most recommended tools in our Mobile Tech Radar. And guess what, it’s also the easiest way to use Suspense in your app.

Note: This article focuses on client-rendered React apps, including React Native apps. For web projects, you may also consider using full-stack frameworks like Remix or Next.js.

React Query: the standard way

If you’re not familiar with React Query, it has a dead simple API:

There’s the loading state, the error state with a retry function, and the success state with data from the server. Simple, elegant. We love React Query.

But now the page grows, and we need to fetch data from another API endpoint. Both queries will fetch in parallel, so we need to keep track of both loading states.

Oh, and both error states too.

Oh and yes I forgot, we may also need to refetch one or both queries if there is an error.

Oh no, our dead simple API is now a mess of boolean conditions 😢 . We only added one query, and our code is already ugly. Just think of what it will look like when we add ten of them…

Ok, not a problem, we’ll just code some kind of utility to combine these states automatically! It’s still possible to forget one query state, but at least it’s much more readable:

It looks like a good solution. You might have come up with it in your project. But there’s just an inherent flaw to this system.

Why loading and error statuses don’t scale

Let’s take a common scenario. Our page also has this gorgeous header:

Now, we want our user John to really feel at home. Let’s get personal, and display his name in the header. Easy right? Just add a user query in the Header component. You’re welcome, John.

Well, we cannot guarantee which of the article or the username will arrive first from the server. Of course, the header cannot have its own spinner, it would be ugly. So we’ll just wait for all queries to resolve before showing the page. Same thing for errors.

But wait, the queries live in different components… Our new shiny utility is powerless! The only way is to lift both queries up to the Home component, combine them together all again, display the spinner, the potential error, and prop drill data down to the initial components…

Several months later, the ++code>Home++/code> page is just an endless list of unrelated queries. We have to engage in some serious spelunking down our component tree to remember why we needed each of them in the first place. We wish we had never called John by his name.

Think about it. Even if the ++code>Article++/code> component has not changed in any way, we were forced to move the queries just to work with another component, which is in a completely different place! (Ever heard of colocation? This is not it.)🙂

All of this for a spinner, that you’ll see for about a second, the first time you open the app.

React query with Suspense

Based on these considerations, let me introduce you to your new favorite React Query option:

We just need one additional component to let the magic happen. I’ll explain it to you in a minute, but for now, we might as well name it ++code>MakeFetchingEasy++/code>. Now believe it or not, but this code does exactly the same thing as before: a global spinner, a global error page, and a retry button for failing queries.

Try it in Expo!

Yes, you are not dreaming, all loading and error states are gone.

You don’t need them anymore.

You don’t need depressing state combining utilities.

But most importantly, you can write your queries where you actually need them.

And remember: React Query dedupes all calls to a given query, then uses its cache as a single source of truth. This means you can call a query in as many components as you want, in any part of your app, as close to the place you need them: it will still be fetched once and only once, and all components will get the same data at the same time. Isn’t that convenient?

So how can this actually work? Before we find out what’s inside this mysterious ++code>MakeFetchingEasy++/code> component, let me first ask a simple question: why do we need React Query in the first place?

React Query is an asynchronous state manager

Besides all the refetching and caching shenanigans, at its core, React Query addresses a very simple problem: reading asynchronous state with React.

Indeed, a React component must always render synchronously. It cannot wait for asynchronous code while rendering, otherwise it would block the UI. There is no such thing as await ++code>await useFetchPosts()++/code> (for now…).

Promises have solved the asynchronous problem quite neatly. A promise has 3 states : pending, error, and success. It starts in the pending state, and eventually returns some data (success state) or throws an error (error state). It’s a simple state machine.

What React Query does is basically decomposing the 3 states of a promise into React state.

What have we learned here? React Query manages two types of state: the query state and the server state. The server state is the actual data that the server returns: the article, the username. But because fetching is an asynchronous operation, this data is not available right away. That “not right away” is the query state: it’s how far we are into getting the server state — loading, error, or success. We could also call it meta-state or transient state, whatever: it’s just not the same thing as the server state.

But in the end, our components only really care about server data — that’s why we make the API call in the first place. So wouldn’t it be nice if the query/meta/transient state was handled by something else?

You guessed it, that’s the role of our super secret ++code>MakeFetchingEasy++/code> component.

The suspense mindset: think boundaries, not state

The name was funny enough, but let’s rename the magic component to ++code>QueryBoundaries++/code>. Here is its implementation:

Besides the actual loading and error views, it’s just made of three simple components: one for loading, one for errors, one for retries.

🔄 QueryErrorResetBoundaries

It provides a retry function to refetch all queries that have failed. The library has already figured it all out for you. Thank you, React Query!

❌ ErrorBoundary

It catches any error that happens within it and displays a fallback component instead. It’s basically React’s version of a ++code>try / catch++/code> statement.

It can be reset after an error, meaning it will remount its children, starting rendering all over again. The ++code>onReset++/code> prop is for additional reset operations, so that’s where we inject the ++code>reset++/code> function provided by ++code>QueryErrorResetBoundaries++/code>. Now, if a query fails, retrying will both refetch the failed query and try re-rendering the children component.

Note: Here we use the ++code>react-error-boundary++/code> package, but error boundaries are part of standard React.

⏳ Suspense

Remember how I said “a React component cannot wait for asynchronous code while rendering”?

Well, sorry, but I was lying.

We can’t just write ++code>await++/code> in it and expect it to work. But recent React versions provide a system where a component can actually pause while rendering to wait for something. We say the component is suspending.

But in the meantime, you have to display something. That’s what ++code>Suspense++/code> does. If one of its children suspends, it displays a fallback component instead. It’s React’s version of ++code>await++/code>:“wait for my children to render, and meanwhile render something else”.

++code>try / catch++/code>, ++code>await++/code>… Do you see it? That’s promise handling with React components!

  • ++code>Suspense++/code> renders the pending state
  • ++code>ErrorBoundary++/code> renders the error state
  • ++code>QueryErrorResetBoundary++/code> lets us restart again in the pending state

Now our components are free to focus on the success state.

And how do we turn our components into promises? With ++code>suspense: true++/code>! The option changes the behavior of ++code>useQuery++/code> to match the promise model. Instead of returning different statuses (error, loading), the hook:

  • suspends the component while fetching (⏳ pending)
  • throws an error if fetching fails (❌ error)
  • resumes rendering once data is available (✅ success )

That’s the closest we get to an asynchronous hook.

But why does it solve our state combining nightmare? Well, in React, state only flows downwards, from parent to children. That’s why need to lift the state up when sibling components need to interact. But throwing an error, that’s a built-in bottom-up data flow! If error happens in a child, it bubbles up until one of its parents catches it and decides to do something with it.

It’s the same idea at the core of Suspense. When a component is not yet ready to render because of missing data, it informs its parent, which in turn suspends and informs its own parent, etc. At some point, one the parent will “catch” the suspension with ++code>React.Suspense++/code>, in order to eventually render something (like a spinner). And when the child component can finally resume rendering, the same goes for the whole chain of its parents.

Note: React Query takes care of this mechanism for you. But if you’re curious, components suspend by throwing something too: the whole promise!

The impact of Suspense on data fetching

Back to our main components. Remember, now we only use the data part of our different useQuery. Let’s make that explicit by wrapping them in their own custom hooks.

Now, another component needs the username? Just call ++code>useUsername++/code> and use it! No more prop drilling: just add one line, and your component can now use server data.

Accessing server state is now as easy and concise as accessing local state with ++code>useState++/code> or ++code>useContext++/code>. In fact, the component doesn’t even know the difference. We abstracted away the asynchronous nature of server state. Now your components just have to focus on the only thing they care about: the server data.

Conversely, the page is now the sole responsible for loading and error states. And it doesn’t care about which queries it needs! There could be 100 queries or none of them, it will always work the same:

  • if any component is missing data, you’ll see a global spinner
  • if anything fails, you’ll see a global error, with a button to refetch that’s missing
  • if everything is available, you’ll see the page right away

What makes this so cool is that you no longer have to think about any of this! Keep adding queries anywhere you need them, it will just work out of the box. You just have to declare what data you need, where you need it.

Since you probably have some kind of layout component for your screens, the best is to just throw the default ++code>QueryBoundaries++/code> in there. Voilà, now all your screens know how to handle queries, even if they don’t have any of them yet. You can always refine the loading behavior later if you want more fine-grained control over how the page will reveal itself. Just remember this simple rule:

  • If a component is outside ++code>QueryBoundaries++/code>, it will be shown all the time (e.g. a header)
  • If it’s inside, it will be hidden behind a global spinner / error until all data is available

Note: Since Suspense pauses rendering, it can easily introduce request waterfalls, for example if you write two useQuery in the same component (think of two consecutive await ). You can learn more about this problem and its solutions in TkDodo’s article.

How do I add Suspense to my project?

Incremental adoption

I have good news for you: you can use Suspense right away in your existing project!

First, ++code>QueryBoundaries++/code> is non-intrusive: if you don’t enable the ++code>suspense++/code> mode of React Query, it will just do nothing. So just add it to all your screen layouts, or at least to each new screen you create: you have all to gain, nothing to lose.

For a small app with basic loading and error screens, removing all the logic will be fast and very satisfying! But if you are not ready to migrate all your spinner logic, keep this in mind: you don’t have to enable the ++code>suspense++/code> mode globally. You can actually add ++code>suspense: true++/code> to any ++code>useQuery++/code> call, so each component can choose what mode it supports. When you create a new screen, add ++code>QueryBoundaries++/code> and go for ++code>suspense: true++/code>. To migrate your other screens, one rule: either all its components use suspense, or none of them, you’re safe. When you’re done, do a quick Search/Replace to remove all ++code>suspense: true++/code>, enable it globally, and enjoy!

But what about compatibility?

Well, another good news: Suspense is available with React 17! For your mobile project, that means React Native 0.64 or Expo SDK 44. On React Query’s side, it’s been supported since v3, but I would definitely recommend using v4, or at least a late v3 version.Keep in mind that Suspense and its support in React Query are still experimental and might change in the future. Besides, the real heart of React 18 concurrent features is only really available in React Native with the new architecture, which is not quite ready yet. But we have used this combo in several production apps with very satisfying results!I hope this article has convinced you to adopt Suspense in your project. Let me know if you spotted a mistake or encountered any issue.

Développeur mobile ?

Rejoins nos équipes