React Suspense for the layman

Dan’s latest talk on React Suspense is the new big-idea in town. It took me a while to understand how it worked, so I thought I’d give an easier to understand version based on the video and the various resources I found online such as this.

Imagine having a UI that depends on lots of different data:

<Profile>
<Info>{info}</Info> // `info` comes from /profile/info
<Avatar img={avatar} /> // `avatar` comes from /avatar?user={}
<Comments comments={comments} /> // `comments` comes from
// /comments?user={}
</Profile>

Not only that, but the <Comments /> component also has more data dependencies:

function Comments({ comments }) {
{comments.map(commentId =>
// CommentsInfo will load /comment?commentId={}
// to get all the data it needs to display
<CommentsInfo commentId={commentId} />
)}
}

So we have a tree with async requests that trigger other async requests.

Problem with this: how do we handle all these requests?

We’d probably have the Profile components handle the requests for the 3 endpoints shown above, and CommentsInfo handle the requests for the /comment endpoint. They’d only render when all the data is finished rendering.

But what do we show the user in the mean time? We can only show a loading spinner for the Profile page, and then once that loads, a bunch of other spinners for each CommentsInfo that is rendered. That’s sort of ugly, we’d have a spinner for every single comment.

We’d end up with an interface that looks something like:

+-------------+
| Profile |
| |
| Info |
| Avatar |
| |
| Comments |
| Spinner... |
| Spinner... |
| Spinner... |
| Spinner... |
+-------------+

What if we only want a single upfront loading spinner without a bunch of loading spinners for the comments section?

Right now if we want to do this we have to transfer all the logic for making all of the requests to a common parent component, Profile in this case.

CommentsInfo would not be in charge of making any request, and would be a functional component. Then Profile would be in charge of showing the single upfront spinner, and render its children when all the requests have responded.

With the help of Redux, this is the current solution to the “spinner problem”.

React Suspense offers a different way of solving this

With React Suspense we can load a “single upfront spinner” without having to move the requests logic to a single parent component.

We still let the CommentsInfo do its own request, as we did initially, but instead of doing it in a lifecycle method such as componentDidMount, we do it in the render using a special function:

const commentInfoFetcher = createFetcher(
fetchCommentInfo // function that returns a fetch() promise
)
function CommentsInfo({ commentId }) {
const commentInfo = commentInfoFetcher.read(commentId)
return <>
Title: {commentInfo.title}
Description: {commentInfo.description}
<>
}

When the CommentsInfo component renders, it will call this commentInfoFetcher.read(commentId)

The first time this function is called, it will throw. Meaning that inside this function there’s an actual throw <something>. It will throw a promise. Here’s how it can look:

function createFetcher(method) {
let resolved = new Map();
return {
read(key) => {
if (!resolved.has(key)) {
throw method(...args).then(val => resolved.set(key, val));
}
return resolved.get(key);
}
};
}

All you have to understand from this is that it will only throw a promise the first time it runs this function (it uses a cache to know whether it was the first time).

What this means is that the CommentsInfo will not render (because something was thrown inside of it), and instead its error will be handled using React’s new Error Boundaries feature.

We can then have the parent Profile component catch these throws (remember we have a CommentsInfo rendered and therefore throwing for each comment):

class Profile extends React.Component {
state = {
isLoading: false,
pendingRequests: 0, // keep a counter of pending requests
}
componentDidCatch(error, info) {
// `error` is a Promise
    // increase the pending
this.setState(prevState => {
return {
pendingRequests: prevState.pendingRequests + 1
}
})

error.then(() => // a CommentsInfo request is finished
// we only set the loading to false, if they all finished,
// avoiding race-conditions
this.setState(prevState => {
const pendingRequests = prevState.pendingRequests - 1
return {
pendingRequests,
isLoading: pendingRequests !== 0
}
})
)
}
render() {
return this.state.isLoading
? 'Spinner...'
: this.props.children
}
}

After all throws have been handled (pendingRequests === 0), we stop the loading and therefore render the components.

This will cause the various CommentsInfo components to be re-rendered. This time however, when the various commentInfoFetcher.read(commentId) get called, they will not throw because it’s not the first time we call them (there’s a cache-hit from our earlier render). They will return the data needed to be displayed.

In other words, we can have a deeply nested tree of components, each triggering various async operations, and have them rendered all at once ONLY after all of their async operations have completed.

This is the sort of stuff it allows for:

I hope this provided a simple explanation to how React Suspense works. It’s a pretty nifty way of using a React component without knowing if it will complete immediately or complete asynchronously.

EDIT turns out Suspense provides new primitives which work differently than explained here. Even though the overall behavior of letting a parent component handle the waiting, Suspense is a lot smarter than the implementation I showed here.

In fact this.deferSetState() as shown in Dan’s video is able to somewhat also catch the thrown promises, and only apply the state changes after all of the child async operations are completed — it’s also clever enough to avoid showing data sooner than intended which could otherwise cause flickering:

What’s even more important than this is that the UI is still usable, even while the underlying tree is trying to load. This means that, while the tree is loading all of its data, you can interact with other portions of the app, and React will forget about the earlier loading tree if you do so.

This solves problems with async race-conditions in an incredibly clever way.

What’s important to note is that this works for async data you’re trying to read. It’s less clear how suspense can work with write operations (does it even make sense?).