Suspense in React 18

heyitsarpit

Arpit

Posted on August 21, 2022

Suspense in React 18

What is Suspense?

The <Suspense /> component is a feature that was introduced along with React.lazy in React 16.6, to enable client side code splitting to load react components only when they're needed.

With React 18, Suspense is a lot more general and works for any asynchronous action you may wish to perform in your components, for e.g. data fetching.

For React 18, according to the release notes, you can technically use suspense for data fetching but for several libraries that support it, it's still an experimental feature.

Suspense is able to detect when your component is "suspended" and renders a fallback for it. The question you may have is, what does it mean for a component to be "suspended"?

How to suspend a component

A suspended component is one that threw a promise that is yet to be fulfilled. Now that sentence may be confusing to you. What do you mean "threw" a promise?

Below is a simple example of a suspended component, this is unusual React code, and you will never need to write this directly, but this is exactly how React knows when a component is suspended.

function Component() {
  throw new Promise((resolve) => {
    console.log('this is a promise that will never resolve');
  });

  return <div>Hello World</div>;
}
Enter fullscreen mode Exit fullscreen mode

In most code bases, we are just used to throwing an error like this:

throw new Error('something went wrong');
Enter fullscreen mode Exit fullscreen mode

But throw in JavaScript is very generic, it will throw whatever you want, it doesn't have to be an error. Along with throw, we use try/catch in to "catch" what was thrown, so we can gracefully handle the error.

<Suspense /> from a developer's perspective, works just like a catch block and tells react that this component is suspended, and we can't yet render it, it has to be handled in some other way.

That other way is to render a fallback component. The most common use case will be to show a loading indicator.

So instead of using isLoading state as we're used to, we will use <Suspense />, and its fallback prop.

Example using isLoading.

function App() {
  const { data, isLoading } = useDataFetcher();

  if (isLoading) {
    return <div>loading...</div>;
  }

  return <Component />;
}
Enter fullscreen mode Exit fullscreen mode

Example using Suspense.

function App() {
  return (
    <React.Suspense fallback={<div>loading...</div>}>
      <Component />
    </React.Suspense>
  );
}
Enter fullscreen mode Exit fullscreen mode

How to write a suspender function

Here is a naive implementation of a function that suspends a component and then resumes after our async action function is "fulfilled".

interface Response<T> {
  status: 'success' | 'pending' | 'error';
  data: T | null;
}

/**
 * A promise tracker that will be updated
 * when promise resolves or rejects
 */
const response: Response<unknown> = {
  status: 'pending',
  data: null
};

/**
 * This is our suspender function
 * that throws promise if it is not fulfilled yet
 */
export function suspend<T>(fn: () => Promise<T>) {
  /**
   * suspender is the promise we will throw
   * so react can re-render when it is fulfilled
   */
  const suspender = fn().then(
    (res) => {
      response.status = 'success';
      response.data = res;
    },
    (error) => {
      response.status = 'error';
      response.data = error;
    }
  );

  switch (response.status) {
    case 'pending':
      throw suspender;
    case 'error':
      throw response.data as T;
    default:
      return response.data as T;
  }
}
Enter fullscreen mode Exit fullscreen mode

In this code example we keep track of a global response variable that tracks the state of our promise, suspender is a new promise that is thrown if the status of our argument is still 'pending'. Otherwise, we can return the resolved data.

When the component is suspended, React has access to the suspender promise. When it is "resolved" or "rejected", React will attempt to re-render the component and this time since data will available we do not need to rely on our fallback.

In our React component, we will use the suspend function like this.

import * as React from 'react';

function sleep(ms: number) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

const action = async () => {
  await sleep(2000);
  return { greeting: 'hello world' };
};

function Component() {
  const data = suspend(action);

  return <div>The Greeting is - {data.greeting}</div>;
}

function App() {
  return (
    <React.Suspense fallback={<div>loading...</div>}>
      <Component />
    </React.Suspense>
  );
}
Enter fullscreen mode Exit fullscreen mode

In the above example <Component /> will throw a promise and in <App />, <React.Suspense /> will catch it, and render the given fallback instead.

This suspend function has several issues though. Component currently does not accept any props and action does not accept any arguments that may be derived from those props, we cannot handle cases where promises are recreated for new function calls.

As a user just building UI components, You will probably never have to worry about these problems since they're supposed to be handled by external libraries.

Problems like caching or multiple promises are handled by whichever data fetching solution you use or something generic like suspend-react which will handle them for you.

Let's rewrite our example with suspend-react.

import * as React from 'react';
import { suspend } from 'suspend-react';

const action = async (name) => {
  await sleep(2000);
  return { greeting: `hello ${name}` };
};

function Component(props) {
  const data = suspend(() => action(props.name), [props.name]);

  return <div>The Greeting is - {data.greeting}</div>;
}
Enter fullscreen mode Exit fullscreen mode

suspend-react works as a suspender function and has a global cache for your actions.
It accepts a list of keys to cache the status of a promise or the result of its execution.

When it comes to data fetching, there are way more specific problems that some libraries like swr will take care of.

Let's see an example with swr.

import * as React from 'react';
import useSWR from 'swr';

function Component() {
  const { data } = useSWR('/api/user', fetcher, { suspense: true });

  return <div>hello, {data.name}</div>;
}

function App() {
  return (
    <React.Suspense fallback={<div>loading...</div>}>
      <Component />
    </React.Suspense>
  );
}
Enter fullscreen mode Exit fullscreen mode

As you can see, the complexity of writing a suspender function is abstracted away from your code, and you end up with a nice clean API surface.

Nested Suspense Boundaries

Just like try/catch blocks, <Suspense /> boundaries can be nested.

You can have a single suspense boundary for several components.

import * as React from 'react';

function App() {
  return (
    <React.Suspense fallback={<Loader />}>
      <UserAvatar />
      <UserName />
    </React.Suspense>
  );
}
Enter fullscreen mode Exit fullscreen mode

Or Several suspense boundaries for each component to handle their suspended state differently.

import * as React from 'react';

function App() {
  return (
    <div>
      <React.Suspense fallback={<Loader />}>
        <UserAvatar />
      </React.Suspense>

      <React.Suspense fallback={<Loader />}>
        <UserName />
      </React.Suspense>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Which strategy you choose will depend on your designs, UX and other requirements.

Further Reading:

💖 💪 🙅 🚩
heyitsarpit
Arpit

Posted on August 21, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related

Suspense in React 18
react Suspense in React 18

August 21, 2022