Async React with Suspense

Photo by Joan Gamell on Unsplash

Async React with Suspense

As React devs we deal with async stuff every day. We know firsthand how awkward and complicated async logic can get.

Now add components into the mix it only gets more complicated.

Suspense was the solution that React came up with to solve for that. Announcement of Suspense happened in 2018. It took a while to stabilize. Finally, with React 19, we will have all the pieces to use it.

The most fascinating thing about Suspense was the use of throw. Yes, you read it right!

Suspense uses throw statements, intended for error handling, as a control flow mechanism.

Yup, 🀯.

Before we dive into the heavy stuff, let's review what async JS is:

Async JavaScript

Let's take this async function:

function fetchText({ text, delay }: DelayText): Promise<string> {  
  return new Promise<string>((resolve) =>  
    setTimeout(() => {  
      resolve(text);  
    }, delay),  
  );  
}

It takes in two parameters:

  • a text β€” which will be returned after a delay

  • a delay β€” a specified duration after which the text is returned

How would we go about consuming such a function?

Variant 1:

import { fetchText } from "./sample.ts";  

async function getTexts(): Promise<[string, string]> {  
  const text1 = await fetchText({ text: "hello 1", delay: 1000 });  
  const text2 = await fetchText({ text: "hello 2", delay: 2000 });  
  return [text1, text2];  
}  

console.time("gettingTexts");  
getTexts().then((res) => {  
  console.log({ res });  
  console.timeEnd("gettingTexts");  
});

If you run this on say node, you should see an output like this:

{
  res: [ "hello 1", "hello 2" ]
}
[3.01s] gettingTexts

If you notice here, I have used console.time that prints out the time taken to execute.

Which in the above case is approximately 3 seconds, i.e., (1 sec for text1) + (2 seconds for text2)

Can we do better?

Variant 2:

import { fetchText } from "./sample.ts";  

async function getTexts(): Promise<[string, string]> {  
  const text1 = fetchText({ text: "hello 1", delay: 1000 });  
  const text2 = fetchText({ text: "hello 2", delay: 2000 });  
  return [await text1, await text2];  
}  

console.time("gettingTexts");  
getTexts().then((res) => {  
  console.log({ res });  
  console.timeEnd("gettingTexts");  
});

By making a small change i.e., moving await to the bottom, we have already improved the runtime of the above script:

{
  res: [ "hello 1", "hello 2" ]
}
[2.01s] gettingTexts

Now it takes 2 seconds! i.e., instead of time of request 1 + time of request 2 it has become max (time of request 1, time of request 2)

Ain't that pretty cool?

This is the essence of concurrent programming. We dispatch requests at the same time and wait for them to finish.

So instead of running one thing at a time, i.e., synchronously. We are running things concurrently and achieving net better performance.

Picture worth a thousand words:

Okay, now what about error handling?

We have good old try/catch.

We just have to wrap the async code in it :

async function getTexts(): Promise<[string, string]> {  
  try {  
    const text1 = fetchText({ text: "hello 1", delay: 1000 });  
    const text2 = fetchText({ text: "hello 2", delay: 2000 });  
    return [await text1, await text2];  
  } catch (e) {  
    console.error("Error fetching texts");  
    return ["error fetching text 1", "error fetching text 2"];  
  }  
}

Now that we have gotten an understanding of what async JS is, let's dive into Async React:

Suspend React

Up until now, we dealt with async resources directly. But what about components that have a async dependency?

For example, let's take the same fetchText function. Instead of logging that value on the console, I want to show it in UI.

The common way of doing this in React is via useEffect (Variant 1):

import { useEffect, useState } from "react";  
import { fetchText } from "../async/sample.ts";  
import { Card } from "./Card/Card.tsx";  

function EffectCard() {  
    const [text, setText] = useState("loading..");  
    useEffect(() => {  
        fetchText({ text: "hello, world!", delay: 1000 })  
            .then((res) => {  
                setText(res);  
            })  
            .catch(() => {  
                setText("error");  
            });  
    }, []);  
    return <Card text={text} />;  
}

Sidenote

For more info on why this may not be a good idea, please read this post by core contributor from react-query :

Despite looking straightforward, useEffect has too many gotchas. Using it properly is challenging. I would recommend using a library which does it for you and react-query is a great option.

Now, this looks fine and well, but there is a problem with this.

If you think about it, this is similar to what we had in Variant 1 for async handling:

    const text1 = await fetchText({ text: "hello 1", delay: 1000 });  
    const text2 = await fetchText({ text: "hello 2", delay: 2000 });

Why so?

Because, useEffect runs after the render is completed.

So we are running things like this:

This is sequential instead of concurrent. You β€” fetch after render.

Let's see how we can improve this (Variant 2):


// move promise outside of React!  
const helloTextPromise = fetchText({ text: "hello, world!", delay: 1000 });  

function EffectCard() {  
    const [text, setText] = useState("loading..");  
    useEffect(() => {  
        helloTextPromise  
            .then((res) => {  
                setText(res);  
            })  
            .catch(() => {  
                setText("error");  
            });  
    }, []);  
    return <Card text={text} />;  
}

With this small change, we have them running concurrently!

Now that is much better. It's easy to get caught up in the framework/library and forget we are still using vanilla JS πŸ˜€

Okay, let's extend the above example (Variant 3):

import { EffectCard } from "./EffectCard.tsx";

function Input() {  
    const [name, setName] = useState("");  
    return (  
        <input  
            name={"userName"}  
            value={name}  
            onChange={(e) => setName(e.target.value)}  
        />  
    );  
}  
function EffectAndInput() {  
    return (  
        <div>  
            <Input />  
            <EffectCard />  {/*Same as above*/}
        </div>  
    );  
}

Now we have two components loading instead of one. One is theEffectComponent other is an input text element.

The effect can be slow or fast. Since we are emulating things here, let's make it slow.

This means that while the async stuff is still running if the user tries to type, it can create a janky experience. I am sure everyone has experienced this at one time or another.

When we try to type something but nothing shows up after a few moments it shows up at once.

This is because of this.

Here we are trying to unblock UI. i.e., we don't want to β€œawait” on something that can take a lot of time to run. In this case, it's a component.

How can we let React know this?

Enter Suspense.

To signal React that this component is loading something that will take some time.

As I mentioned at the beginning we need to:

throw promise

Yup 🀯 (it still does).

By throwing a promise from a component, you let React know that there is some async activity going on. So React will suspend the component.

React can do other things while suspending the component!

This is Async React!

Let's refactor the above example to use Suspense.

We need to add a little logic to stop throwing the promise when it's finished. For that, we have this small utility:

type Status = "Loading" | "Done" | "Error";  

function createResource<T>(promise: Promise<T>) {  
    let status: Status = "Loading";  
    let result: T | null = null;  
    let error: Error | null = null;  
    promise  
        .then((res) => {  
            status = "Done";  
            result = res;  
        })  
        .catch((err) => {  
            status = "Error";  
            error = err;  
        });  
    return {  
        read(): T {  
            switch (status) {  
                case "Loading":  
                    throw promise; /* throw until promise is still running */  
                case "Error":  
                    throw error;  
                case "Done":  
                    return result!;  /* return once done */
                default: {  
                    return status;  
                }  
            }  
        },  
    };  
}

As you can see, we are throwing the promise till it's in Loading state and returning the result once it's done.

With this, let us convert the EffectCard to a SuspendedCard:

import { fetchText } from "../async/sample.ts";  
import { Card } from "./Card/Card.tsx";  
import { createResource } from "../async/create-resource.ts";  

// Same as before - start the promise outside of React life cycle
const helloTextResource = createResource(  
    fetchText({ text: "hello, world!", delay: 1000 }),  
);  
function SuspendedCard() {  
    const helloText = helloTextResource.read();  
    return <Card text={helloText}></Card>;  
}

⚠️ Don't create the resource inside the Component! Because that would mean that a new resource will be created each time the component re-renders. Which is not something we wish to have (Infinite loops!).

Keep the components idempotent.

Now let's refactor Variant 3 to use the SuspendedCard:


import { Suspense } from "react";  
import { SuspendedCard } from "./SuspendedCard.tsx";  
import { Input } from "./Input.tsx";  

function SuspendedEffectAndInput() {  
    return (  
        <Suspense fallback={<h1>loading..</h1>}>  
            <Input />  
            <SuspendedCard />  
        </Suspense>  
    );  
}  

export { SuspendedEffectAndInput };

Now isn't this cool?

Few things to note here though:

  • The Suspense the wrapper is called the Suspense Boundary. This is where you provide a fallback i.e., while the component is suspended you can show something else. A loading indicator, for example.

  • The Suspense boundary is granular. You can put it anywhere you like. Directly above the <SuspendedCard /> or like I have done in the snippet.

The Suspense boundary is so flexible because we are literally throwing the promise!

When you throw something, we can have any number of layers above. We handle it wherever we wish. Now throwing stuff other than errors makes sense. Also, we must handle it!

What about errors? πŸ€”

If you had noticed in the createResouce function, we had this case too:

case "Loading":  
    throw promise;  
case "Error":  
    throw error; /* error scenario */  
case "Done":  
    return result!;  
default: {  
    return status;

So, in case of an error, we throw the error instead of the original promise.

To handle it, we need to wrap it in a ErrorBoundary:

import { Suspense } from "react";  
import { SuspendedCard } from "./SuspendedCard.tsx";  
import { Input } from "./Input.tsx";  
import { ErrorBoundary } from "react-error-boundary";  /* you have to install this library */

function SuspendedEffectAndInput() {  
    return (  
        <ErrorBoundary fallback={<h1>Some error occurred!</h1>}>  
            <Suspense fallback={<h1>loading..</h1>}>  
                <Input />  
                <SuspendedCard />  
            </Suspense>  
        </ErrorBoundary>  
    );  
}  

export { SuspendedEffectAndInput };

This is similar to wrapping it with try/catch block!

This whole approach is called render as you fetch

Let's push this a bit further.

We can even load the component code dynamically via lazy .

So that means that both code and data can be loaded concurrently!

Let's see how that can be accomplished:


import { lazy, Suspense } from "react";  
import { Input } from "./Input.tsx";  
import { ErrorBoundary } from "react-error-boundary";  
import { createResource } from "../async/create-resource.ts";  
import { fetchText } from "../async/sample.ts";  
const Card = lazy(() =>  
    import("./Card/Card.tsx").then((mod) => ({ default: mod.Card })),  
);  /* async logic to get code */ 

const helloTextResource = createResource(  
    fetchText({ text: "hello, world!", delay: 1000 }),  
); /* async logic to get data */ 

function SuspendedResourceCard({  
    resource: { read },  
}: {  
    resource: { read: () => string };  
}) {  
    const helloText = read();  
    return <Card text={helloText}></Card>;  
}  

function FinalVariant() {  
    return (  
        <ErrorBoundary fallback={<h1>Some error occurred!</h1>}>  
            <Suspense fallback={<h1>loading..</h1>}>  
                <Input />  
                <SuspendedResourceCard resource={helloTextResource} />  
            </Suspense>  
        </ErrorBoundary>  
    );  
}

As you can see, we are loading both code and data concurrently!

Suspense boundaries can handle both of them together without us having to manage the low-level details. The best part is React can do other things like update UI when the user types on the input (no more jank!)

Another cool thing is now with RSC we have a way to start loading promises on the server and then Suspend them on the client!

Some additional resources if you want to deeper into this:

https://overreacted.io/algebraic-effects-for-the-rest-of-us/

This Youtube video by Jack details on how to leverage these things on the server:

Also, we don't have to use the createResource method. The React team is shipping with a new hook in React 19 called use :

For a live version of all the code samples you can check them out here:

https://github.com/varenya/react-suspense-blog

All right folks that's it, I hope this was helpful!

Β