Creating suspense in React 16.2

Fetching data, showing loading states and rendering responses are traditionally all responsibilities of the React developer. Announced at JSConf Iceland, the new “Suspense” feature moves many of these responsibilities into React itself.

My first reaction to this announcement was one of irrational fear-of-change.

“These are things a developer can already do, why does React need to provide them?”

Emotional knee-jerks aside, my curiosity remained. Needing to know how Suspense works, I decided to see how closely I could recreate it in React 16.2.

Oh the suspense

First, a refresher on how suspense is used.

The CharacterList component renders a list of Star Wars characters. To get this data, it calls read on a “fetcher” which is created by one of the proposed React features createFetcher. When read is called and the data has not been resolved, React will suspend that render until the data becomes available. This behaviour is invisible to the developer. From their perspective, they happily call read and the data is there. On the library’s side of the fence, this is much harder to take care of.

We know that read must return a value. We also know when we don’t have a value, the fetcher should tell React to suspend rendering. Working out how to communicate “suspend rendering please” from the context of this function was the biggest head scratcher for me.

Throwing up

The answer is to throw a Promise.

When a fetcher is created we setup a cache that maps arguments passed to read to the resolved values. When read is called the fetcher checks whether there is a value for the argument. If there is, it returns the value immediately. If there isn’t, it throws a Promise that executes the fetch and adds the value to the cache when it resolves.

As bizarre as this may look, throwing a Promise is the crucial link between the fetcher and the React world.

If we make sure our fetcher throws the Promise within a React error boundary, we can catch that Promise and now be in the context of a React component. The Loading component shown in the JSConf demo is probably the simplest example of this.

The implementation of Loading can be cobbled together like:

When componentDidCatch is called, Loading receives the Promise thrown by the fetcher. It uses a render prop to communicate to it’s children whether a fetch is in progress or not. We are now able to change render behaviour based on the status of a fetcher. 🙌

deferSetState

The deferSetState function was demoed as a way for components to indicate that the next render might result in a fetcher executing. To create a use-case for this, let’s expand CharacterList to have pagination. When the user clicks on the previous or next buttons, nothing on the UI should change until the new characters are fetched and then rendered.

I was not able to replicate the same syntax as shown in the demo so I created a DeferredState component that provides the deferSetState function instead.

The responsibility of DeferredState is to manage any state changes that could result in a fetcher throwing a Promise. It does this by providing the deferSetState function and “owning” the state that is passed to fetchers.

When deferSetState is called, DeferredState will:

  1. Know the current state of children.
  2. Know the next state of children from the deferSetState argument.
  3. Try to render children with the next state. This may throw a Promise.
  4. Catches the Promise and renders children back in current state.
  5. When the Promise resolves, next state becomes current state and children is rendered.

The implementation looks like:

The key point is that DeferredState can recover from a fetcher throwing a Promise by rendering a state it knows will succeed. An interesting result of the children-as-function approach, is that we can use a try-catch statement rather than error boundaries.

With all this in place, we have nailed our use-case. When the user navigates between pages, rendering the character list is “suspended” while the next page is fetched. 🎉

Why does React need to provide Suspense?

What I have shared is a fragile attempt at re-creating Suspense based on my understanding. That understanding was informed by watching one video and reading a bunch of tweets. I am happy with what I have done — I found it interesting and hopefully you have too.

Now to the question of whether there are any technical reasons why Suspense should be shipped in React. I can’t give an informed answer to this. I don’t know enough about how this feature works in reality. Maybe there are no technical reasons. Whether this is the case is not important.

What is important, is the React team being engaged in the community, recognising best practices and making these practices easier for everyone to follow. Suspense is just the latest example of this.

I can’t wait to get my hands on the real thing.

Update: why React must provide Suspense

As it turns out, there are technical reasons for React to provide Suspense. Let’s update the PaginatedCharacterList example.

In the above example we have added aCharacterListWithCounter component. The call to read now happens in the render method of CharacterListWithCounter. In the original example, read was called from the render method of DeferredState. Moving read to a child component means the Promise thrown by the fetcher is not caught in the try/catch of DeferredState. Instead, it is caught in the error boundary that has been added to DeferredState.

This is cool - we can now read from a fetcher anywhere in the component tree below DeferredState. Except that adding this feature exposes a fundamental problem of implementing Suspense outside of React. 😅

Taking another look at the example, Counter is a simple component that shows a button and the number of times it has been clicked. It keeps track of this number in it’s own state. Every time we navigate to a new page of Star Wars characters, we see that this counter is reset. 🤔

When componentDidCatch is called, React will have unmounted the component tree underneath the error boundary. Unmounting a component will blow away all of it’s state. This is the reason why Counter is being reset each time we navigate between pages.

A possible work around would be to lift the state of all components below DeferredState into DeferredState. That way, the component tree could be mounted with the same state it had when it was unmounted. This solution is painful at best, and impossible if you are rendering a 3rd party component with state.

From inside React, the internal state of each component is known. This makes recovering from a fetcher exception easier. It can use the internal component states to remount the component tree exactly how it looked before the exception. Alternatively, if React knows that the exception came from a fetcher, it might be able to avoid unmounting the component tree altogether.

Special thanks to Dan Abramov from the React team for pointing this reason out to me. With this update, I feel that I have satisfied my curiosity on the subject.

If you are interested, have a poke around the repo. Thanks to Github user Axnyff for contributing a Placeholder component. 😃

Here is a video of me talking about this topic at ReactSyd.

Thanks for reading!

More questions? Reach out to me on twitter @pete_gleeson

Friend of the front-end @Atlassian