What is React Suspense and Async Rendering?

December 18, 2023

1,477 words

Post contents

In our last article, I introduced React Server Components (RSC) as a primitive to enable more efficient server-side React usage.

I also hinted in the conclusion of that article that due to the nature of RSCs we'd be able to add on to our knowledge and utilize data fetching.

Let's talk about data fetching, first by putting server-side behavior to the side, then we'll reintroduce the server-APIs soon after.

To do this, I want to introduce you to three new APIs: the use Hook, the <Suspense> component, and Async React Server Components.

What is the React use Hook?

The React use Hook enables you to load data asynchronously in your components where data fetching is mission-critical.

Let's say that you're in a traditional client-side rendered app and want to fetch data from the server. If you're not using a library like TanStack Query (which you should be), you might have something like this:

jsx
const [data, setData] = useState({
loading: true,
result: null,
error: null,
});
// Please use TanStack Query
useEffect(() => {
fetchUser()
.then((serverData) => {
setData({ error: null, loading: false, result: serverData });
})
.catch((err) => {
setData({ error: err, loading: false, result: null });
});
}, []);

While this works and useEffect can be used this way, useEffect is not a built-in mechanism for asynchronous data loading.

Instead, React 18.3 (in canary release at the time of writing) introduces a new Hook: use.

This hook allows you to pass a promise to it to load data:

jsx
import {use, cache} from "react";
const UserDisplay = () => {
const result = use(fetchUser());
return <p>Hello {result.name}</p>;
};
// Without `cache`, a new instance of a promise would
// be returned to `use` on every render. That's bad.
const fetchUser = cache(() => {
// ...
});

Here, we're using use in tandem with React's cache function to avoid having to run useMemo on fetchUser.

Now React will treat the result as if it were not a promise, so that you can access properties and render them directly inside of your JSX. Effectively use acts as an await for promises in your client components.

If your only objective is to load data on the client, I'd still highly suggest using TanStack Query or something similar. After all, even with use you likely want to take into consideration the following:

  • Caching results
  • Refetching with new inputs
  • Abort signals to avoid timing issues

What is the <Suspense> component?

The React <Suspense> component allows you to add a loading state to your components needing to use asynchronous APIs; such as the new use Hook.

Take the <UserDisplay> component from before. To add a loading indicator to the <UserDisplay> component, add a <Suspense> component in the parent component alongside a fallback={} property:

jsx
function App() {
return (
<Suspense fallback={<p>Loading...</p>}>
<UserDisplay promise={promise} />
</Suspense>
);
}

Reusing Loading Indicators

Loading indicators may be important to show in-progress data fetching, but users don't often like seeing a dashboard with 30 different loading spinners.

Because of this, React has made handling multiple data sources easy using <Suspense>; just wrap multiple use Hook components inside of a single <Suspense> component:

jsx
const UserDisplay = ({timeout}) => {
const result = use(fetchUser({ timeout }));
return <p>Hello {result.name}</p>;
};
function App() {
return (
<Suspense fallback={<p>Loading...</p>}>
<UserDisplay timeout={1500} />
<UserDisplay timeout={3000} />
</Suspense>
);
}
// Pretend this is fetching data from the server
const fetchUser = cache(({ timeout }) => {
return new Promise((resolve) => {
setTimeout(() => {
resolve({
name: "John Doe",
age: 34,
});
}, timeout ?? 1000);
});
});

To sidestep this behavior, wrap each <UserDisplay> in their own <Suspense>:

jsx
function App() {
return (
<>
{/* Will show "Loading..." for 3 seconds while
waiting for BOTH promises to resolve */}
<Suspense fallback={<p>Loading...</p>}>
<UserDisplay timeout={1500} />
</Suspense>
<Suspense fallback={<p>Loading...</p>}>
<UserDisplay timeout={3000} />
</Suspense>
</>
);
}

How do I handle rejected promises in <Suspense>?

While use and <Suspense> handle resolved promises just fine, they alone will not handle rejected promises passed to the use Hook.

To handle rejected promises in Suspense, you'll need to use an <ErrorBoundary> class-based component which utilizes the getDerivedStateFromError lifecycle method.

Let's see how we can do this ourselves:

jsx
const UserDisplay = () => {
const result = use(fetchUser());
return <p>Hello {result.name}</p>;
};
function App() {
return (
<ErrorBoundary>
<Suspense fallback={<p>Loading...</p>}>
<UserDisplay />
</Suspense>
</ErrorBoundary>
);
}
class ErrorBoundary extends Component {
state = { error: null };
static getDerivedStateFromError(error) {
return { error };
}
render() {
if (this.state.error) {
return <p>There was an error: {JSON.stringify(this.state.error)}</p>;
}
return this.props.children;
}
}

Using use on the server

Now let's move back to server-land

We know that we can make server-only components, that don't reinitialize on the client, right? Now what if we could load the data on the server and not have it passed to the client either?

Well, luckily for us - we already have a mechanism for loading data in React that's async:

tsx
const ServerComp = () => {
/* This works, but is not the best way of doing
things on the server */
const data = use(fetchData())
return <ChildComp data={data}/>
}
const Parent = () => {
/* We don't need a Suspense component here, since
the server will wait for the promise to resolve before
sending data to the client */
return <ServerComp/>;
}

Here, we're seeing an imaginary ChildComp rendered with data passed from the server - this data is never fetched on the client thanks to how React Server Components work.

But wait a moment - we're on the server. use accepts any promise... What if... What if we just polled our database directly?

tsx
const ServerComp = () => {
/* This also works, but is still not the best way
of doing things in server components */
const data = use(fetchOurUserFromTheDatabase())
return <ChildComp data={data}/>
}
// Still using cache... For now...
const fetchOurUserFromTheDatabase = cache(() => {
// ...
})

This works!

What are React Async Server Components?

While use is undoubtably useful for client apps, server components have a better option available to us: async components.

Here, we mark our component as being async and simply await the promise function to resolve it prior to reaching our JSX:

jsx
// No need for `cache`!
async function fetchOurUserFromTheDatabase() {
// ...
};
async function UserDetails() {
const user = await fetchOurUserFromTheDatabase();
return <p>{user.name}</p>;
}

We can then use it as if it were any other server component:

jsx
export default function Home() {
return <UserDetails />;
}

Not only is the developer experience for this component authoring better, but it's drastically more performant due to how its internals work.

If that's the case why don't we use async components on the client as well?

According to the React team, there are technical limitations around using async components on the client that make it infeasible to use on the client.

A note about async server components

Something to keep in mind is that while normal React Server Components can use some Hooks (useId, useSearchParams, etc) async server components cannot use any hooks of any kind.

Conclusion

In this article, we took a look at React's official solutions for async rendering behavior. This is great to see the team make strides in this area; I think most apps are going to end up utilizing these heavily.

However, this is only half of the story for React's async support. Next up, we'll talk about React Server Actions, which enables the client to make RPC-like calls back to the server and execute server code for us.

Can't wait to talk about what you learned about? Join our Discord and tell us what you think about the Suspense API!

Creative Commons License

Subscribe to our newsletter!

Subscribe to our newsletter to get updates on new content we create, events we have coming up, and more! We'll make sure not to spam you and provide good insights to the content we have.