Original address: medium.com/react-in-de…

Julian Burr

In this article, I don’t want to go too deep into the implementation details and inner workings of React Suspense, because there have been plenty of good blog posts, videos, and discussions about it. Instead, I prefer to focus on how Suspense will affect how we think about load state and architecture applications during application development.

Suspense

Suspense may not be heard of or even understood at all, so I’ll give you a brief summary of Suspense.

At the JSConf conference in Iceland last year, Dan Abramov introduced Suspense. Suspense is described as an API that greatly improves the developer experience when it comes to solving the problems of asynchronous data fetching in React applications. This is exciting because every developer building a dynamic Web application knows that this is a major pain point and one of the reasons for the huge sample code.

Suspense also changes the way we think about load state; it should not be coupled to load components or data sources, but should exist as A UI concern. Our app should display a meaningful spinner in the user experience. Suspense helps us do this by decoupling the following concerns:

Suspense doesn’t care why you pause (how many times), so a simple spinner can be combined with scenarios like code splitting, data loading, image loading, etc. Whatever the tree down there needs. If the network speed is fast enough, you can not even display the Spinner.

Suspense is not only useful for data loading, it can even be applied to any asynchronous data flow. Examples include code splitting or image loading. The React.lazy component in conjunction with the Suspense component is available in the latest stable release of React, which allows us to split up dynamically imported code without manually handling load states. A full Suspense API with data loading features will be released later this year, but you can use it early through alpha.

Suspense is designed to give components the ability to “pause” rendering, for example, when they need to load additional data from an external resource. React does not attempt to re-render the component until the data is loaded.

React uses Promise to implement this feature. A component can throw a Promise when the Render method is called (or any method called when the component renders, for example, the new static method getDerivedStateFromProps). React catches the Promise and moves up the component tree to find the nearest Suspense component, which acts as a boundary. Suspense components receive a prop named Fallback, and components in fallback are rendered immediately as soon as any components in its subtree are paused.

React also keeps track of the promises that are thrown. Once a Promise in a component is resolved, React attempts to continue rendering the component. Because we assume that since the Promise has been resolved, this means that the paused component already has all the data it needs to render correctly. To do this, we use some form of cache to store data. The cache depends on whether the data is available each time it is rendered (if it is, it is read as if from a variable). If the data is not ready, fetch is triggered and then a Promise is raised for React to catch. As mentioned above, this is not unique to data loading; any asynchronous operation that can be described using Promise can take advantage of Suspense, and code splitting is obviously a very obvious and popular example.

Suspense’s core concept is very similar to error boundaries. The error boundary was introduced in React 16 as the ability to catch uncaught exceptions anywhere in the application, and again it handles all exceptions thrown from under that component by placing it in the tree (in this case, any component with a componentDidCatch lifecycle method). Similarly, Suspense components capture any promises thrown by child components, but we don’t need a specific component to act as a boundary, because Suspense components themselves allow us to define fallbacks to decide on alternate rendering components.

Such features significantly simplify the way we think about loading states in applications and align our mental models as developers with those of UX and UI designers.

Designers typically do not think about data sources, but rather the logical organization and information hierarchy of the user interface or application. You know who else doesn’t care about data sources? The answer is users. No one likes thousands of spinners loading, some of them flashing for just a few milliseconds, and the page content bouncing up and down when the data is loaded.

Why is Suspense called a big breakthrough?

The problem

To understand why Suspense reverses rules, let’s take a look at how we currently handle data loading in our apps.

The original method is to store all the required information in a local state. The code might look something like this:

class DynamicData extends Component {
  state = {
    loading: true.error: null.data: null
  };

  componentDidMount () {
    fetchData(this.props.id)
      .then((data) = > {
        this.setState({
          loading: false,
          data
        });
      })
      .catch((error) = > {
        this.setState({
          loading: false.error: error.message
        });
      });
  }

  componentDidUpdate (prevProps) {
    if (this.props.id ! == prevProps.id) {this.setState({ loading: true }, () => {
        fetchData(this.props.id)
          .then((data) = > {
            this.setState({
              loading: false,
              data
            });
          })
          .catch((error) = > {
            this.setState({
              loading: false.error: error.message
            });
          });
      });
    }
  }

  render () {
    const { loading, error, data } = this.state;
    return loading ? (
      <p>Loading...</p>
    ) : error ? (
      <p>Error: {error}</p>
    ) : (
      <p>The Data the loaded 🎉</p>); }}Copy the code

This seems wordy, right?

We load the data when the component mounts and store it in local state. In addition, we track error and load status through local state. This looks familiar, doesn’t it? Even if you’re not using state but some kind of abstraction, there are probably still loads of triples scattered throughout your application.

I don’t think this approach is inherently wrong (it satisfies the needs of simple use cases, and it can be easily optimized by, for example, separating the logic of the request data into the new method first), although it doesn’t scale well, the developer experience will certainly be better. To illustrate, I’ve listed some of the problems with this approach:

1. 👎 Ugly triplet → terrible DX

Loading and error states in the Render method are defined by triples, which complicate our code unnecessarily. We are not describing the render function, but the component tree.

2. 👎 boilerplate code → bad DX

We had to write a lot of boilerplate code to manage all the states: request data while mounting, update loading state and store data to state on success, or store error messages on failure. We repeat all the steps above for each component that requires external data.

3. 👎 limited data and loading state → bad DX & UX

We find that state processing and storage are all in one component, which means that there will be many other spinners that need to load data, and if we have different components that rely on the same data, there will be a lot of unnecessary API call code. Going back to the point I made earlier, the mental model that makes loading state dependent on data sources doesn’t seem to be correct. In this way we found that the load state was strongly coupled with the data load and components, which limited us to dealing with the problem within the component (or using hacks to solve it) rather than using it in a broader application scenario.

4. 👎 retrieve data → bad DX

The logic that you need to retrieve the data after changing the ID is a redundant implementation. We’re both going to initialize the data in componentDidMount and additionally check for id changes in componentDidUpdate.

5. 👎 flashing spinner → terrible DX

If the user’s Internet speed is fast enough, showing a Spinner for a few milliseconds is far worse than showing nothing at all, making your application clunky and slow. So perceived performance is key.


Now do you see the downside of this model? This may not come as a surprise to many, but to me, it doesn’t really say much about the developer and user experience.

So, now that we know what the problems are, how can we fix them?

Use the Context to improve

Redux has been the solution to these problems for a long time. But with the release of the React 16 release of the new Context API, we have another great tool to help define and expose data globally while making it easy to access it in a deeply nested component tree. So for the sake of simplicity, we’ll use the latter here.

First, we convert all the data originally stored in the component state into the Context Provider so that other components can share the data. We can also expose the method of loading data through a provider so that our component simply fires the method and reads the loaded data through the Context Consumer. The recent React 16.6 release of contextType makes it more elegant and less cumbersome.

Providers can also be used as caching to avoid multiple unnecessary network requests if the data already exists or is being loaded when triggered by another component.

const DataContext = React.createContext();

class DataContextProvider extends Component {
  // We want to store a variety of data in this provider
  // Therefore we use a unique key as the key name for each dataset object
  // Load status
  state = {
    data: {},
    fetch: this.fetch.bind(this)}; fetch (key) {if (this.state[key] && (this.state[key].data || this.state[key].loading)) {
      // The data has either been loaded or is being loaded, so there is no need to request data again
      return;
    }

    this.setState(
      {
        [key]: {
          loading: true.error: null.data: null
        }
      },
      () => {
        fetchData(key)
          .then((data) = > {
            this.setState({
              [key]: {
                loading: false,
                data
              }
            });
          })
          .catch((e) = > {
            this.setState({
              [key]: {
                loading: false.error: e.message } }); }); }); } render () {return <DataContext.Provider value={this.state} {. this.props} / >; } } class DynamicData extends Component { static contextType = DataContext; componentDidMount () { this.context.fetch(this.props.id); } componentDidUpdate (prevProps) { if (this.props.id ! == prevProps.id) { this.context.fetch(this.props.id); } } render () { const { id } = this.props; const { data } = this.context; const idData = data[id]; return idData.loading ? (<p>Loading...</p>
    ) : idData.error ? (
      <p>Error: {idData.error}</p>
    ) : (
      <p>The Data the loaded 🎉</p>); }}Copy the code

We can even try to remove the triplet code from the component. Let’s place the loading spinner higher up the component tree so that it acts on more than one component. Now that we have the loading state in the context, it’s incredibly easy to put the loading spinner where we want to display it, isn’t it?

This is still a problem because the data load method is triggered in the first place only when the AsyncData component starts rendering. Of course, we could push the data loading method up the tree rather than triggering it within this component, but that doesn’t really solve the problem, it just moves it elsewhere. This also affects the readability and maintainability of the code, and suddenly AsyncData components rely on other components to load data for them. Such reliance is neither clear nor correct. Ideally, you want components to work as independently as possible, so you can put them anywhere and not rely on other components in the surrounding component tree at a particular location.

But at least we managed to centralize data and load state in one place, which is progress. Since we can place the provider anywhere, we can use the data and functionality anytime, anywhere, which means that other components can take advantage of it (instead of using redundant code) and can reuse loaded data, eliminating unnecessary API calls.

To understand this, let’s look at the initial problem:

1. 👎 Ugly triple

Nothing has changed, all we can do is move the triplet somewhere else, but that doesn’t solve the DX problem

2. 👍 boilerplate code

We removed all the boilerplate code we had previously needed. All we need to do is trigger the data loading method and read the data and loading state from the context, so we reduce a lot of repetitive code, and the rest is readable and maintainable code.

3. 👍 Restricted data and loading status

Now we have global state that can be read anywhere in the application. So we’ve improved the situation a lot, but we haven’t solved all the problems: The loading state is still coupled to the data source, and if we want to display the loading state based on multiple components loading their respective data, we still need to know which data source it is and manually check the individual loading state.

4. 👎 Obtain data again

The problem was not resolved.

5. 👎 flashing spinner

Nor does it solve the problem.


I think we can all agree that this is a solid improvement, but it still leaves some unanswered questions.

Suspense appearance

How can we use Suspense to do better?

First, we can remove the context, and the data processing and caching will be done by the Cache provider, which can be anything. Context, localStorage, normal objects (even Redux if you need it), etc. All of these providers just help us store the requested data. On every data request, it first checks to see if there is a cache. If it does, it reads directly; if it doesn’t, it makes a data request and throws a Promise, which stores the backup information in whatever is used for the cache until the Promise Resolve. Once the React component triggers a rerender, everything is available at that point. Obviously, things get more complicated with more complex use cases when cache invalidation and SSR issues are taken into account, but that’s the general point.

This unresolvable caching problem is one reason why Suspense in the form of data loading isn’t included in the current stable version of React. If you’re curious, you can use the experimental React-Cache package early, but it’s not stable and is sure to get a huge overhaul in the future.

In addition, we can remove all loading State triples. More importantly, Suspense will load data conditionally at component rendering time and will suspend the rendering if the data is not cached, rather than loading data at mount and update time. This might seem like an anti-pattern (after all, we’re told not to do this), but consider that if the data is already in the cache, the provider simply returns it and the rendering can proceed.

import createResource from './magical-cache-provider';
const dataResource = createResource((id) = > fetchData(id));

class DynamicData extends Component {
  render () {
    const data = dataResource.read(this.props.id);
    return <p>The Data the loaded 🎉</p>; }}Copy the code

Finally we can place the boundary component and render the fallback component we defined earlier when the data is loaded. Suspense components can be placed anywhere, and as explained earlier, these boundary components can capture promises bubbling up in all their children.


class App extends Component {
  render () {
    return (
      <Suspense fallback={<p>Loading...</p>} ><DeepNesting>
          <ThereMightBeSeveralAsyncComponentsHere />
        </DeepNesting>
      </Suspense>); }}// We can specifically use multiple boundary components.
// They do not need to know which component is paused
// Or why, they just catch any promises that bubble up
// Then proceed as expected.
class App extends Component {
  render () {
    return (
      <Suspense fallback={<p>Loading...</p>} ><DeepNesting>
          <MaybeSomeAsycComponent />
          <Suspense fallback={<p>Loading content...</p>} ><ThereMightBeSeveralAsyncComponentsHere />
          </Suspense>
          <Suspense fallback={<p>Loading footer...</p>} ><DeeplyNestedFooterTree />
          </Suspense>
        </DeepNesting>
      </Suspense>); }}Copy the code

I think this will definitely make the code clearer and the logical data flow easier to read from top to bottom. Now what are the problems that have been solved?

1. ❤️ Ugly triple

The fallback component is rendered by the boundary component, which makes the code easier to follow and more intuitive. Loading state becomes a UI concern and is decoupled from data loading.

2. ❤️ boilerplate code

We solved this problem more perfectly by removing the code that triggers data loading in the component lifecycle method. It also envisions a future of libraries that act as cache providers, which you can switch whenever you want to change the storage solution.

3. ❤️ Restricted data and loading status

Now that we have an explicit boundary component for loading state, we don’t care where or why the data loading method is fired. As soon as any component in the boundary component is paused, the loading state is immediately rendered.

4. ❤️ Obtain data again

Since we can read the data source directly in the Render method, React automatically triggers and reloads the data as long as the id passed in is different, and we don’t have to do anything. The Cache Provider does this for us.

5. 👎 flashing spinner

This is still an open question. 🤔


These are huge improvements, but we still have one problem……

However, since we’re using Suspense, React has another trick to help us use it.

Ultimate solution: concurrent mode

Concurrent mode, previously also known as asynchronous React, is another upcoming feature that lets React process multiple tasks at once and switch between them based on defined priorities, effectively allowing React to do multiple tasks. Andrew Clark gave a great talk at the 2018 React Conf that included a perfect example of how it affects users. I don’t want to go into too much depth here, because there’s already an article that goes into a lot of detail.

However, with the addition of concurrency modes to our application, Suspense will have new features controlled through prop on components. If we pass a maxDuration attribute, the boundary component will delay displaying the Spinner until the specified time is exceeded, thus avoiding unnecessary spinner flickering. At the same time, it ensures that the Spinner displays in the shortest possible time, fundamentally solving the same problem and making the experience as user-friendly as possible.

// This line of code is not required
ReactDOM.render(<App />, document.getElementById('root')); Reactdom.createroot (document.getelementById (' root ')).render(<App />);
Copy the code

To be clear, this doesn’t make the data load faster, but it does for the user, and the user experience is significantly improved.

In addition, concurrency modes are not required in Suspense. Suspense as we’ve seen before, Suspense also works well and solves many problems without concurrency patterns. Concurrency is the icing on the cake. It’s not necessary but it would be nice to have it.