React Suspense: Lessons Learned While Loading Data

Avatar of Adam Rackis
Adam Rackis on

Suspense is React’s forthcoming feature that helps coordinate asynchronous actions—like data loading—allowing you to easily prevent inconsistent state in your UI. I’ll provide a better explanation of what exactly that means, along with a quick introduction of Suspense, and then go over a somewhat realistic use case, and cover some lessons learned.

The features I’m covering are still in the alpha stage, and should by no means be used in production. This post is for folks who want to take a sneak peek at what’s coming, and see what the future looks like.

A Suspense primer

One of the more challenging parts of application development is coordinating application state and how data loads. It’s common for a state change to trigger new data loads in multiple locations. Typically, each piece of data would have its own loading UI (like a “spinner”), roughly where that data lives in the application. The asynchronous nature of data loading means each of these requests can be returned in any order. As a result, not only will your app have a bunch of different spinners popping in and out, but worse, your application might display inconsistent data. If two out of three of your data loads have completed, you’ll have a loading spinner sitting on top of that third location, still displaying the old, now outdated data.

I know that was a lot. If you find any of that baffling, you might be interested in a prior post I wrote about Suspense. That goes into much more detail on what Suspense is and what it accomplishes. Just note that a few minor pieces of it are now outdated, namely, the useTransition hook no longer takes a timeoutMs value, and waits as long as needed instead.

Now let’s do a quick walkthrough of the details, then get into a specific use case, which has a few lurking gotchas.

How does Suspense work?

Fortunately, the React team was smart enough to not limit these efforts to just loading data. Suspense works via low-level primitives, which you can apply to just about anything. Let’s take a quick look at these primitives.

First up is the <Suspense> boundary, which takes a fallback prop:

<Suspense fallback={<Fallback />}>

Whenever any child under this component suspends, it renders the fallback. No matter how many children are suspending, for whatever reason, the fallback is what shows. This is one way React ensures a consistent UI—it won’t render anything, until everything is ready.

But what about after things have rendered, initially, and now the user changes state, and loads new data. We certainly don’t want our existing UI to vanish and display our fallback; that would be a poor UX. Instead, we probably want to show one loading spinner, until all data are ready, and then show the new UI.

The useTransition hook accomplishes this. This hook returns a function and a boolean value. We call the function and wrap our state changes. Now things get interesting. React attempts to apply our state change. If anything suspends, React sets that boolean to true, then waits for the suspension to end. When it does, it’ll try to apply the state change again. Maybe it’ll succeed this time, or maybe something else suspends instead. Whatever the case, the boolean flag stays true until everything is ready, and then, and only then, does the state change complete and get reflected in the UI.

Lastly, how do we suspend? We suspend by throwing a promise. If data is requested, and we need to fetch, then we fetch—and throw a promise that’s tied to that fetch. The suspension mechanism being at a low level like this means we can use it with anything. The React.lazy utility for lazy loading components works with Suspense already, and I’ve previously written about using Suspense to wait until images are loaded before displaying a UI in order to prevent content from shifting.

Don’t worry, we’ll get into all this.

What we’re building

We’ll build something slightly different than the examples of many other posts like this. Remember, Suspense is still in alpha, so your favorite data loading utility probably doesn’t have Suspense support just yet. But that doesn’t mean we can’t fake a few things and get an idea of how Suspense works.

Let’s build an infinite loading list that displays some data, combined with some Suspense-based preloaded images. We’ll display our data, along with a button to load more. As data renders, we’ll preload the associated image, and Suspend until it’s ready.

This use case is based on actual work I’ve done on my side project (again, don’t use Suspense in production—but side projects are fair game). I was using my own GraphQL client, and this post is motivated by some of the difficulties I ran into. We’ll just fake the data loading in order to keep things simple and focus on Suspense itself, rather than any individual data loading utility.

Let’s build!

Here’s the sandbox for our initial attempt. We’re going to use it to walk through everything, so don’t feel pressured to understand all the code right now.

Our root App component renders a Suspense boundary like this:

<Suspense fallback={<Fallback />}>

Whenever anything suspends (unless the state change happened in a useTransition call), the fallback is what renders. To make things easier to follow, I made this Fallback component turn the entire UI pink, that way it’s tough to miss; our goal is to understand Suspense, not to build a quality UI.

We’re loading the current chunk of data inside of our DataList component:

const newData = useQuery(param);

Our useQuery hook is hardcoded to return fake data, including a timeout that simulates a network request. It handles caching the results and throws a promise if the data is not yet cached.

We’re keeping (at least for now) state in the master list of data we’re displaying:

const [data, setData] = useState([]);

As new data comes in from our hook, we append it to our master list:

useEffect(() => {
  setData((d) => d.concat(newData));
}, [newData]);

Lastly, when the user wants more data, they click the button, which calls this:

function loadMore() {
  startTransition(() => {
    setParam((x) => x + 1);
  });
}

Finally, note that I’m using a SuspenseImg component to handle preloading the image I’m displaying with each piece of data. There are only five random images being displayed, but I’m adding a query string to ensure a fresh load for each new piece of data we encounter.

Recap

To summarize where we are at this point, we have a hook that loads the current data. The hook obeys Suspense mechanics, and throws a promise while loading is happening. Whenever that data changes, the running total list of items is updated and appended with the new items. This happens in useEffect. Each item renders an image, and we use a SuspenseImg component to preload the image, and suspend until it’s ready. If you’re curious how some of that code works, check out my prior post on preloading images with Suspense.

Let’s test

This would be a pretty boring blog post if everything worked, and don’t worry, it doesn’t. Notice how, on the initial load, the pink fallback screen shows and then quickly hides, but then is redisplayed.

When we click the button that’s loads more data, we see the inline loading indicator (controlled by the useTransition hook) flip to true. Then we see it flip to false, before our original pink fallback shows. We were expecting to never see that pink screen again after the initial load; the inline loading indicator was supposed to show until everything was ready. What’s going on?

The problem

It’s been hiding right here in plain sight the entire time:

useEffect(() => {
  setData((d) => d.concat(newData));
}, [newData]);

useEffect runs when a state change is complete, i.e., a state change has finished suspending, and has been applied to the DOM. That part, “has finished suspending,” is key here. We can set state in here if we’d like, but if that state change suspends, again, that is a brand new suspension. That’s why we saw the pink flash on initial load, as well subsequent loads when the data finished loading. In both cases, the data loading was finished, and then we set state in an effect which caused that new data to actually render, and suspend again, because of the image preloads.

So, how do we fix this? On one level, the solution is simple: stop setting state in the effect. But that’s easier said than done. How do we update our running list of entries to append new results as they come in, without using an effect. You might think we could track things with a ref.

Unfortunately, Suspense comes with some new rules about refs, namely, we can’t set refs inside of a render. If you’re wondering why, remember that Suspense is all about React attempting to run a render, seeing that promise get thrown, and then discarding that render midway through. If we mutated a ref before that render was cancelled and discarded, the ref would still have that changed, but invalid value. The render function needs to be pure, without side effects. This has always been a rule with React, but it matters more now.

Re-thinking our data loading

Here’s the solution, which we’ll go over, piece by piece.

First, instead of storing our master list of data in state, let’s do something different: let’s store a list of pages we’re viewing. We can store the most recent page in a ref (we won’t write to it in render, though), and we’ll store an array of all currently-loaded pages in state.

const currentPage = useRef(0);
const [pages, setPages] = useState([currentPage.current]);

In order to load more data, we’ll update accordingly:

function loadMore() {
  startTransition(() => {
    currentPage.current = currentPage.current + 1;
    setPages((pages) => pages.concat(currentPage.current));
  });
}

The tricky part, however, is turning those page numbers into actual data. What we certainly cannot do is loop over those pages and call our useQuery hook; hooks cannot be called in a loop. What we need is a new, non-hook-based data API. Based on a very unofficial convention I’ve seen in past Suspense demos, I’ll name this method read(). It is not going to be a hook. It returns the requested data if it’s cached, or throws a promise otherwise. For our fake data loading hook, no real changes were necessary; I simple copy-and-pasted the hook, then renamed it. But for an actual data loading utility library, authors will likely need to do some work to expose both options as part of their public API. In my GraphQL client referenced earlier, there is indeed both a useSuspenseQuery hook, and also a read() method on the client object.

With this new read() method in place, the final piece of our code is trivial:

const data = pages.flatMap((page) => read(page));

We’re taking each page, and requesting the corresponding data with our read() method. If any of the pages are uncached (which really should only be the last page in the list) then a promise is thrown, and React suspends for us. When the promise resolves, React attempts the prior state change again, and this code runs again.

Don’t let the flatMap call confuse you. That does the exact same thing as map except it takes each result in the new array and, if it itself is an array, “flattens” it.

The result

With these changes in place, everything works as we expected it to when we started. Our pink loading screen shows once on their initial load, then, on subsequent loads, the inline loading state shows until everything is ready.

Parting thoughts

Suspense is an exciting update that’s coming to React. It’s still in the alpha stages, so don’t try to use it anywhere that matters. But if you’re the kind of developer who enjoys taking a sneak peek at upcoming things, then I hope this post provided you some good context and info that’s useful when this releases.