Understanding Suspense-ful coding in React

Understanding Suspense-ful coding in React

How Suspense works, the gotchas with using it, and how to integrate your Promise and async APIs with it for easy loading states.

TLDR; <Suspense> is cool and a useful way to handle async loads in your React apps, but it has some tricky gotchas about data flow and caching if you try to use it with bare Promises. An easy way to solve this and use Suspense today is suspension.


I've been working this week on a little webapp to practice integrating React apps with Firebase backends. As part of this project, I pulled in reactfire, which is the first module I've used that had first class support for the new React <Suspense> component. I'd heard about this component before but it was finally time to do a deep dive into what it was, how it worked, and how I could integrate it more deeply into my React apps going forward.

What is Suspense?

Suspense was the first component from React's experimental Concurrent mode to be merged into the non-experimental release (way back in 16.6). Suspense's job is to detect the need for an async load and render a fallback loading UI.

function CalendarApp() {
  const [viewedDay, setViewedDay] = useState(new Date());
  // Assuming that CalendarDayView is ready to work with Suspense,
  // this renders your loading spinner while today's data is loading.
  return (<main>
    <Suspense fallback={<LoadingSpinner />}>
      <CalendarDayView date={viewedDay} />
    </Suspense>
  </main>);
}

If that was all it did, it would be basically syntactic sugar over the tried-and-true pattern of if (callStatus === "loading") { return <LoadingSpinner />; }. But Suspense has a superpower that very few people are talking about, but to understand it we have to first understand how this component works.

How does Suspense work?

Suspense works by mildly abusing the throw statement. A component or hook that wants to indicate that it is still loading and needs more time should throw a Promise that will resolve when the component is ready for its render to be reattempted.

function CalendarDayView({ date }) {
  // Let's imagine our ORM has a cache of days' agendas we can check
  const cacheResult = calendarDB.cachedValue({ date });

  // To hook into Suspense, we recognize if we need to load and
  // throw a Promise that resolves when we're ready to try again.
  if (!cacheResult) {
    const loadingPromise = calendarDB.load({ date });
    loadingPromise.then((result) => {
      calendarDB.cache({ date, value: result });
    });
    throw loadingPromise;
  }

  // Otherwise do the render
  return (
    <h1>Calendar for {cacheResult.dayString}</h1>
    // ... and so on
  );
}

When we throw a Promise like this, React climbs the virtual DOM to find the nearest <Suspense> component and hands it the Promise.

This removes the whole tree under that Suspense from the rendered DOM and replaces it with the fallback.

This is how Suspense can give us superpowers. Because the throw interrupts our component's render process, we are guaranteed that if we get past it we are not loading. In the Calendar example above, we can be certain that if we get to the JSX at the bottom of the page then cacheResult is non-null and defined so we no longer have to guard against it being a missing value during a load. When the Promise that we threw resolves or rejects the <Suspense> will automatically try to re-render its children, giving us another chance to draw our calendar.

Gotcha 1 - Handling Errors

So one small gotcha here is that we've nicely separated out the "loading" case, but our component would still have to deal with the "API failed" case itself. Well, the React team have a suggestion for that too - again just throw your Errors and catch them with an error-boundary higher up in the tree. If you're committing to use Suspense, this is almost always the right answer as well since it neatly separates your components into loading, failed, and success cases. This is especially easy thanks to the react-error-boundary package.

Gotcha 2 - Avoiding Infinite Loading

There is a big gotcha with this system: how do you make sure you have your result when Suspense tries again? Since Suspense throws away the tree under it, the state of the component that threw the Promise (and by extension your hooks' state) will be destroyed during the load.

This is fine if you're loading from an API like our imaginary ORM above, where you can easily get the value if it's already cached. But if you're loading something from an API that always returns a Promise, like fetch, how do you get the result when you are told to retry? If you just naively call again, you can get stuck in an infinite load where every retry kicks off another call.

To escape this spiral, you need a cache that exists outside of your <Suspense>. This can be as complex as a fully cached data layer like Firestore or Apollo or it can be as simple as a stateful hook outside of your <Suspense>.

How do I use this with my Promises today?

So, to recap:

  1. <Suspense> components catch Promises that their children throw if they're not ready to render.

  2. They remove their children from rendering and display the Fallback instead. This destroys the children's state.

  3. Because of this, you're almost always going to want a cache for the data so it's accessible when you get asked to re-render.

Putting all this together, the easiest way to convert existing Promise-based accesses to ones ready for Suspense would be to have a top-level cache that your components could send Promises to and later access the results synchronously. If you are already using a heavy datastore layer like Apollo or Redux, then you can use that. But if you weren't using those, you were kinda left out in the cold. So, I built it:

Suspension - hook any async API to Suspense

Suspension is this exact cache and call setup. You wrap your components in the <SuspensionRig> cache provider, which can also act as both a Suspense and/or an error boundary. Then, whenever you need data from a Promise, you pass it to suspension via a hook and it handles the logic of deciding whether to load, throw, or return a value for you.

Here's how we'd rewrite our Calendar app from above to use Suspension. First we swap out our base Suspense for a SuspensionRig:

import { SuspensionRig } from 'suspension';

function CalendarApp() {
  const [viewedDay, setViewedDay] = useState<Date>(new Date());
  return (<main>
    <SuspensionRig fallback={<LoadingSpinner />}>
      <CalendarDayView date={viewedDay} />
    </SuspensionRig>
  </main>);
}

And then we rip out our cache-or-load logic from above and replace it with one call to the useSuspension hook:

import { useSuspension } from 'suspension';

function CalendarDayView({ renderDay }: { renderDay: Date }) {
  // useSuspension takes a function that triggers your async work,
  //  a cache key to track the result, and the dependencies that
  //  trigger a new load (passed as args to your load function).
  const today = useSuspension(
    (date: Date) => calendarDB.load({ date }),
    'load-day-view',
    [renderDay]
  );

  // The hook guarantees that `today` will always be defined.
  return (
    <h1>Calendar for {today.dayString}</h1>
    // ... and so on
  );
}

All our logic about caching and trigger loads and throwing values gets collapsed into the hook and Suspension handles it all for us.

await React.future()

Learning about <Suspense> this past week has reignited my excitement about React. The whole experimental concurrent feature set feels like a new, simplified mental model for understanding concurrent loads in our UI.

Please check out Suspension - npm install suspension is ready to go. I hope it helps you dive into <Suspense> sooner and with more confidence - let me know if you find it useful or run into issues. The project's Issues and PRs are open for requests and contributions.

View Suspension on Github

Did you find this article valuable?

Support How to App by becoming a sponsor. Any amount is appreciated!