DEV Community

tmns
tmns

Posted on

Creating better user experiences with React 18 Suspense and Transitions

What are you talking about?

React 18 is around the corner and one of the big things coming along with it is Concurrent Mode. The general idea is that it's going to allow for concurrent / interruptible rendering, which in turn will enable us to create more responsive and enjoyable applications for our users.

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 />);
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
Enter fullscreen mode Exit fullscreen mode

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());
  });
}
Enter fullscreen mode Exit fullscreen mode

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}`}>
// ...
Enter fullscreen mode Exit fullscreen mode

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! πŸ€™

Discussion (2)

Collapse
pcjmfranken profile image
Peter Franken

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

But the user took an action with the intent to navigate away from the old content immediately - they are done with it for now. Replacing the previous content with a loading indicator reassures the user that their click registered, that their request is being worked on, and where on the screen they can expect for it to be rendered.

Nice overview and useful links nevertheless!

Collapse
tmns profile image
tmns Author

Thanks!

A loading indicator is still shown in our new example however; so, the user continues to be reassured that their click registered, their request is being worked on, and where they can expect the new content to be rendered πŸ™‚

The difference is the old state isn’t blown away completely while waiting for the new state. In general, this gives the perception of a faster load, even if the loading time is actually the same. Further, on a slow connection, having content one can still interact with rather than just a loading indicator makes for a much nicer experience.

In this simple example, we just have some static content, but imagine it’s dynamic with other actions the user can perform. Maybe while waiting the user changes their mind and wants to perform a different action. There are many possibilities.

At the end of the day, you know your users best. For a lot of folks though β€” this is going to be an easy win for UX.