DEV Community

Cover image for What is React Suspense and Async Rendering?
Corbin Crutchley for This is Learning

Posted on • Originally published at unicorn-utterances.com

What is React Suspense and Async Rendering?

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:

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 });
        });
}, []);
Enter fullscreen mode Exit fullscreen mode

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:

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(() => {
    // ...
});
Enter fullscreen mode Exit fullscreen mode

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:

function App() {
    return (
        <Suspense fallback={<p>Loading...</p>}>
            <UserDisplay promise={promise} />
        </Suspense>
    );
}
Enter fullscreen mode Exit fullscreen mode

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:

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);
    });
});
Enter fullscreen mode Exit fullscreen mode

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

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>
        </>
    );
}
Enter fullscreen mode Exit fullscreen mode

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:

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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:

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/>;
}
Enter fullscreen mode Exit fullscreen mode

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?

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(() => {
    // ...
})
Enter fullscreen mode Exit fullscreen mode

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:

// No need for `cache`!
async function fetchOurUserFromTheDatabase() {
    // ...
};

async function UserDetails() {
  const user = await fetchOurUserFromTheDatabase();
  return <p>{user.name}</p>;
}
Enter fullscreen mode Exit fullscreen mode

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

export default function Home() {
  return <UserDetails />;
}
Enter fullscreen mode Exit fullscreen mode

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!

Top comments (1)

Collapse
 
fpaghar profile image
Fatemeh Paghar

the client-side considerations of async components, shedding light on the technical limitations acknowledged by the React team. This insight adds a crucial layer of understanding, emphasizing the importance of aligning async component usage with the specific capabilities and constraints of client-side rendering.