Async React
Demo application to showcase how Suspense
works.
Posted on June 2, 2024
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:
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:
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} />;
}
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?
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:
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:
Demo application to showcase how Suspense
works.
All right folks that's it, I hope this was helpful!
Posted on June 2, 2024
Sign up to receive the latest update from our blog.