Creating better user experiences with React 18 Suspense and Transitions

What are you talking about?

This post will focus on two Concurrent Mode features in particular, Suspense for Data Fetching and Transitions, which will allow us to create much better loading experiences (and let's face it: we desperately need it!).

Up until now, when needing to fetch data before showing some UI that depends on that data, we typically rendered a loading state in its place, for example a loading spinner or skeleton, until the request resolved with the necessary data.

As an example, let's look at the following CodeSandbox:

Every time we change tabs, the Content component for said tab fetches some data. While that data is being fetched, we render a little loading component in the content's place. This isn't the worst experience and indeed it's more-or-less the standard way we see loading states implemented in apps today.

Wouldn't it be nicer though if we didn't show that in-between loading state at all? What if, instead, we held on to the previous state of the UI until the data was ready? To be fair, we can technically achieve this with React 17 if we really want to but it's definitely a challenge to get right and not very straight-forward. React 18, on the other hand, makes this very simple:

Now instead of switching tabs immediately we stay on the tab we're on and continue to show its content until the new tab's content is ready. We effectively have taken complete control over how we want our loading states to behave. The result is a more seamless and less jarring experience for the user.

This is now a good time to point out that the demo above is a rewrite of the awesome SolidJS demo showcasing its implementation of Suspense and Transitions, which its had for a while now. In general SolidJS and its community is incredible and I highly recommend folks check it out.

If you're a "just show me the code" type of person then that's it! Fork the demo and make it yours! If you want a bit more of an explanation though, continue on!

How does it work?

The magic in this demo, as hinted at in the introduction, lies in the use of Suspense for data fetching and the new useTransition hook.

Setup

First though, in order to enable any of these features, we need to make a small change to how we render our root. Instead of rendering via ReactDOM.render, we use the new ReactDOM.createRoot:

import ReactDOM from "react-dom";
import App from "./App";

const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(<App />);

And just like that we have access to Concurrent Mode!

Suspense (for data fetching)

Now that we're up and running with the new features, we can examine in more details our use of Suspense:

<Suspense fallback={<Loader />}>
  {tab === 0 && <Content page="Uno" resource={resource} />}
  {tab === 1 && <Content page="Dos" resource={resource} />}
  {tab === 2 && <Content page="Tres" resource={resource} />}
</Suspense>

Up until now, we've typically used Suspense when lazy loading components. However in this case our components aren't lazy loaded at all! Instead of suspending on the async loading of the component itself, we're now suspending on the async loading of data within it.

Checking within Content, we see a peculiarly simple component:

function Content({ page, resource }) {
  const time = resource.delay.read();

  return (
    <div className="tab-content">
      This content is for page "{page}" after {time.toFixed()}
      ms.
      <p>{CONTENT[page]}</p>
    </div>
  );
}

Normally we would expect to see a check for time, which would probably be set in state, for example maybe something like:

const [time, setTime] = useState();

useEffect(() => {
  resource.then((data) => {
    setTime(data)
  })
}, [])

return time &&
  (<div className="tab-content">
      This content is for page "{page}" after {time.toFixed()}
      ms.
      <p>{CONTENT[page]}</p>
    </div>
  );

However, instead we see the jsx being unconditionally returned. Further time isn't set in state to trigger a rerender, rather its set to resource.delay.read(). And that's the clue to how this is all working!

You'll see when looking into our fakeAPI file, that resource.delay is actually a special kind of promise, which in our naive implementation taken from the official React examples, is essentially a simplified mock of what something a React 18 compatible data fetching library would provide (and what Relay already does provide!).

The API itself is an implementation detail, the main take-away is that in React 18, Suspense wrapped components will be able to continuously check if the async data a component is attempting to read has been resolved, throwing and continuing to render the fallback until it's ready.

Transitions

With this new use of Suspense, implementing components that depend on async data is much more straight-forward. By itself though, we still can't easily control our loading states. We need the other major piece of our puzzle: the new and shiny useTransition hook.

Note that this hook is really all about defining some state changes as transitional rather than urgent, meaning that if some new work needs to be done during rendering of those changes, React should interrupt the rendering and perform that new work first. For a great in depth example of how this can be used to improve UX, check out this guide from core React team member Ricky Hanlon.

In our case, we're going to use useTransition to tell React that setting the new tab and setting the new resource (which in turn fetches the tab's data) are both transitional state changes and as such we want it to hold off on rendering the resulting UI.

This is accomplished by wrapping both of our transitional state changes in a call to startTransition, which we get from useTransition:

const [isPending, startTransition] = useTransition();

function handleClick(index) {
  startTransition(() => {
    setTab(index);
    setResource(fetchData());
  });
}

You will also notice that along with startTransition we get another utility: isPending. As you can probably guess, this returns true while our transitional changes are still ongoing. This can be used to show an extra piece of loading state so the user knows something is happening in the background.

In our example, that's the "loading bar" at the top, along with some styling changes to the tabs and the content:

<GlobalLoader isLoading={isPending} />
// ...
<div className={`tab ${isPending ? "pending" : null}`}>
// ...

And that's really it! Once you get past the theory and jargon, the practical implementation is very straight-forward. It basically comes down to just wrapping transitional changes with startTransition and handling other UX details with isPending 🙌

That's all folks

If you can't tell, I'm super excited for React 18 and Concurrent Mode. Along with streaming server rendering, this release is going to be a complete game changer as far as React goes. I can't wait to use it in "the real world" to make applications more snappy and users more happy!

Hope you got something out of this as always questions / comments are more than welcome! 🤙

16