16
Creating better user experiences with React 18 Suspense and Transitions
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!
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.
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!
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.
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
🙌
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