Skip to content

Commit

Permalink
updated suspense blog ghsots
Browse files Browse the repository at this point in the history
  • Loading branch information
loganzartman committed Nov 4, 2023
1 parent 9721525 commit d808a5d
Show file tree
Hide file tree
Showing 2 changed files with 238 additions and 119 deletions.
258 changes: 139 additions & 119 deletions src/app/blog/posts/diy-suspense-in-react.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,158 +5,180 @@ tags:
- React
- Suspense
- hooks
date: 2023-10-16
date: 2023-11-04T12:00:00
---

# DIY Suspense in React

When I started learning the new features in [React 18][react 18], I was pretty interested in Suspense. Suspense lets authors build libraries that do asynchronous tasks, like fetching data, in a way that's easier for developers to use. Instead of having to track whether data is loading, developers can write components like the data is available instantly. It can serve a similar role to `async/await` in that it makes a Promise look synchronous.
**Not familiar with Suspense?** Check out my [two-minute intro-by-example](/blog/suspense-in-2-minutes).

The thing is, when I read [the React docs][react suspense docs], I only really learned how to use the `<Suspense>` component. `<Suspense>` is a wrapper component that displays a _fallback state_ (e.g. loading spinner) when any of its children _suspend_. But that doesn't tell me how to write something that suspends. It only tells me how to work with things that already suspend.
When I started learning the new features in [React 18][react 18], I was pretty interested in [Suspense][react suspense docs].

I think that the React team [doesn't intend][suspense in data frameworks] for you as a developer to write the code that _does the suspending_. Instead, they imagine that you'll use libraries which are already integrated with Suspense. In that case, all you need to do is handle the fallback states with the `<Suspense>` component. They likely have thought a lot about this.
Even after reading the docs, I was still curious about something: how do you suspend? In other words, what triggers the fallback state in a `<Suspense>` boundary? The docs dance around this part; they don't even define the component that suspends.

But this isn't super satisfying to me—it feels like I've only seen half the API. So here's a very basic overview of the suspending part of it. Even if this has limited pracitcality, I think it's helped my mental model to understand the basics of how it works. Keep in mind that Suspense is based on promises, so you'll need to know how they work first.
I think that the React team [doesn't intend][suspense in data frameworks] for most people to write code that suspends. Instead, they imagine you'll use libraries that are already integrated with Suspense. In that case, all you need to do is handle the fallback states with the `<Suspense>` component. They likely have thought a lot about this and have good reasons for it!

## How to suspend
But this isn't super satisfying to me—it feels like I've only seen half the API. So here's a very basic overview of the suspending part of it. Even if this has limited pracitcality, I think it's helped my mental model to understand the basics of how it works. Keep in mind that Suspense is based on [promises][promise], so you'll need to know how they work first.

Let's create a function called `readHelloWorld()`. It will simulate data fetching by suspending for one second, then return the string "Hello world!". Here's how we'll use it:
## When to suspend

```tsx
function MyComponent() {
return <div>React says: {readHelloWorld()}</div>;
}
Let's start with a promise. This promise waits for one second and then resolves `'Hello world!'`:

function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<MyComponent />
</Suspense>
);
}
```tsx
const helloPromise = new Promise<string>((resolve) => {
setTimeout(() => {
resolve('Hello world!');
}, 1000);
});
```

When we render `<MyPage>`, the result should be this:

1. The user sees a "Loading..." for one second
2. The loading indicator is replaced with: "React says: Hello world!"
Without Suspense, we can hook the promise into the React lifecycle using state.

Let's look at how we can implement `readHelloWorld`.
There are three possible states of the promise:

### Throw a promise
1. Loading (or "pending")
2. Resolved
3. Errored

`readHelloWorld` is a getter function that suspends for a second before returning a value. By convention, we name it `read`—because it can suspend.

To suspend, we need a Promise that represents the thing we're waiting for. For this example, we'll create a Promise that resolves after a 1-second timeout:
Let's create a hook that accepts a promise and outputs its state:

```tsx
const helloWorldPromise = new Promise((resolve) => {
setTimeout(() => {
resolve('Hello world!');
}, 1000);
});
import {useState, useEffect, useMemo} from 'react';

function usePromise<T>(promise: Promise<T>): {
loading: boolean;
result: T | null;
error: any;
} {
const [loading, setLoading] = useState(true);
const [result, setResult] = useState<T | null>(null);
const [error, setError] = useState(null);

useEffect(() => {
setLoading(true);
promise
.then((result) => {
setResult(result);
setLoading(false);
})
.catch((error) => {
setError(error);
setLoading(false);
});
}, [promise]);

return useMemo(() => ({loading, result, error}), [loading, result, error]);
}
```

Let's store the result when the promise resolves:
Now we can use it in a component:

```tsx
let helloWorld;
export default function App() {
return <MyComponent promise={helloPromise} />;
}

helloWorldPromise.then((result) => {
helloWorld = result;
});
function MyComponent({promise}) {
const {loading, result, error} = usePromise(promise);

if (loading) return <div>Loading...</div>;
if (error) return <div>Error!</div>;
return <div>Promise says: {result}</div>;
}
```

Now, we can implement `readHelloWorld`:
[View in sandbox](https://codesandbox.io/s/no-suspense-f53xct)

If we wanted to do this with Suspense, how would that look?

## How to suspend

Let's try to replace the `usePromise()` hook, using Suspense instead of React state. We need to handle the same three Promise states: loading, resolved, and errored. Let's start with the code:

```tsx
function readHelloWorld() {
if (!helloWorld) {
throw helloWorldPromise;
import {Suspense} from 'react';
import {ErrorBoundary} from 'react-error-boundary';

const promises = new WeakSet();
const results = new WeakMap();
const errors = new WeakMap();

function readPromise<T>(promise: Promise<T>): T {
if (!promises.has(promise)) {
promise.then((result) => results.set(promise, result));
promise.catch((error) => errors.set(promise, result));
promises.add(promise);
}
return helloWorld;

const result = results.get(promise);
if (result) return result;

const error = errors.get(promise);
if (error) throw error;

throw promise;
}
```

That's it!
And here's the updated usage:

Here's a [sandbox][simple sandbox] where you can try this out.

Let's walk through what happens during the first render:
```tsx
export default function App() {
return (
<ErrorBoundary fallback={<div>Error</div>}>
<Suspense fallback={<div>Loading...</div>}>
<MyComponent promise={helloPromise} />
</Suspense>
</ErrorBoundary>
);
}

1. `MyComponent` calls `readHelloWorld()`
1. `helloWorld` is undefined, so the function throws `helloWorldPromise`
1. React catches the promise at the nearest `<Suspense>`
1. The render did not complete, so the children are replaced with the`fallback`
function MyComponent({promise}: {promise: Promise<string>}) {
const result = readPromise(promise);
return <div>Promise says: {result}</div>;
}
```

Roughly a second passes and the promise resolves. Then:
[View in sandbox](https://codesandbox.io/s/diy-suspense-ppgqkc)

1. React rerenders the children of the `<Suspense>` **from scratch**, as if they were remounted
1. `MyComponent` calls `readHelloWorld()` again
1. `helloWorld` contains the result, so the function returns normally and rendering completes
This is a little different. There's no more React state. In fact, `readPromise()` isn't even a hook! It doesn't need to be.

React treats thrown promises separately from thrown errors. Promises propagate to the nearest `<Suspense>` boundary, while errors propagate to the nearest [Error Boundary][error boundary], or are thrown if no error boundaries exist.
We use global stores, `results` and `errors`, to track the outcome of the Promise. We use a set, `promises`, to attach our callbacks only once.

### TL;DR
Here's the key part: there are three possible outcomes when we call this function. They mirror our three promise states:

To suspend, we throw a promise. The React tree is replaced with the fallback state at the nearest `<Suspense>` boundary. When the promise resolves, React rerenders the children of the `<Suspense>` boundary. This time, we don't throw the Promise, and the rendering completes.
1. resolved - `return result`
2. errored - `throw error`
3. loading - `throw promise`

## A more detailed example
The special part is the last one: `throw promise`. When a Promise is thrown during a React render:

The above example isn't very practical, because there's only one promise and one _resource_ being read: the helloWorld string. Below, I'll implement a simple Suspense wrapper for `fetch` with caching.
1. the Promise propagates up to the nearest `<Suspense>` boundary
2. React catches the Promise
3. the children of the `<Suspense>` component are replaced with the `fallback`
4. rendering continues outside of the `<Suspense>` boundary

```tsx
const stationID = 'KSEA'; // Seattle
const endpoint = `https://api.weather.gov/stations/${stationID}/observations/latest`;
In this way, it works very similarly to throwing an `Error` and catching it with an error boundary. But, there's a bit more to it.

const promises = new Map<string, Promise<Response>>();
const results = new Map<string, any>();
## Suspense and the lifecycle

const fetchJson = (url) => {
// initiate the request and update the global stores
let promise = promises.get(url);
if (!promise) {
promise = fetch(url);
promise
.then((response) => response.json())
.then((json) => results.set(url, json));
promises.set(url, promise);
}
Eventually we want the component to _un_-suspend. When React catches the Promise in a `<Suspense>` boundary, it calls `.then()` to track when the Promise resolves. When the Promise resolves, React re-renders all the children of the `<Suspense>` boundary.

// create a "resource" that can be read
return {
read() {
const result = results.get(url);
if (!result) {
throw promise;
}
return result;
},
};
};

function MyComponent() {
const result = fetchJson(endpoint); // fetching starts here
const data = result.read(); // rendering suspends here
const temperature = data.properties.temperature.value;

return <div>The temperature now is {temperature}°C</div>;
}
```
After a component throws, React doesn't have any magic way to jump back to where the render left off. Instead, it simply re-renders the entire component from scratch. Since suspending can interrupt rendering before all hooks have run, React can't maintain any of the state from the incomplete render. So, when a component is un-suspended, it's like it's remounted.

And here's that as a [sandbox][detailed sandbox].
If the suspending code is implemented correctly, it will not throw the Promise once it's resolved. This allows the rendering to complete, and the component to un-suspend.

`fetchJson` fires off a request and returns a "resource" whose value can be `read` later. This is one pattern that separates the data fetching from suspending. This means that we could fetch multiple things at once before suspending. Or, we could fetch in a parent component and pass the resource through a `<Suspense>` boundary into a child.
## TL;DR

Keep in mind that we need a data store outside of the `<Suspense>` boundary. We need to track when the data is available to decide whether to throw the promise. We can't track this inside a component that's suspending, because its state is reset when it suspends. Here, I just use a global `Map`. But, you could also pass the store down through a React context.
To suspend, we throw a Promise. React catches the Promise at the nearest `<Suspense>` boundary. The children of the `<Suspense>` component are temporarily replaced with its `fallback` tree. When the Promise resolves, React rerenders all the children of the `<Suspense>` boundary. This time, we don't throw the Promise, and the rendering completes.

## Just use `use()`

**Note:** `use()` is currently only available in the canary release of React (18.3).

I mentioned how Suspense feels a bit like `await`. It makes a Promise look synchronous by interrupting rendering and restarting once the promise resolves. I also mentioned how the React team is a bit hesitant about people writing their own code that suspends.
I think Suspense feels a bit like `await`. It makes a Promise look synchronous by interrupting rendering and restarting once the Promise resolves. I also mentioned how the React team is a bit hesitant about people writing their own code that suspends.

Luckily, there's a cool upcoming hook called `use`. Yep, just `use`. It's very straightforward:
Luckily, React provides a cool hook called `use`. Yep, just `use`. It's very straightforward:

> `use` is a React Hook that lets you **read the value of a resource like a Promise** or context.
> [the docs][use docs]
Expand All @@ -165,40 +187,38 @@ But it's actually more powerful than a hook, and closer to a regular function:

> Unlike all other React Hooks, `use` can be called within loops and conditional statements like `if`. Like other React Hooks, the function that calls use must be a Component or Hook.
To me, this looks like a Suspense-based `await`!
To me, this looks like a Suspense-based analog to `await`!

In both of our examples from before, we could simply `use()` the promises instead of suspending manually and dealing with state.
In our example from before, we could simply `use()` the Promise instead of suspending manually and dealing with state.

Here's the data fetching example rewritten with `use`:
Here's the example rewritten with `use`:

```tsx
const promises = new Map<string, Promise<any>>();

const fetchJson = (url) => {
let promise = promises.get(url);
if (!promise) {
promise = fetch(url).then((response) => response.json());
promises.set(url, promise);
}
return promise;
};

function MyComponent() {
const result = fetchJson(endpoint); // fetching starts here
const data = use(result); // rendering suspends here
const temperature = data.properties.temperature.value;
export default function App() {
return (
<ErrorBoundary fallback={<div>Error</div>}>
<Suspense fallback={<div>Loading...</div>}>
<MyComponent promise={helloPromise} />
</Suspense>
</ErrorBoundary>
);
}

return <div>The temperature now is {temperature}°C</div>;
function MyComponent({promise}) {
const result = use(promise);
return <div>Promise says: {result}</div>;
}
```

And a [sandbox][use sandbox]
[View in sandbox](https://codesandbox.io/s/use-suspense-jt3cq7)

[promise]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise
[react 18]: https://react.dev/blog/2022/03/29/react-v18
[react suspense docs]: https://react.dev/reference/react/Suspense
[suspense in data frameworks]: https://react.dev/blog/2022/03/29/react-v18#suspense-in-data-frameworks
[simple sandbox]: https://codesandbox.io/s/simple-suspense-z2xhgv
[detailed sandbox]: https://codesandbox.io/s/simple-data-fetching-2rl9jg
[use sandbox]: https://codesandbox.io/s/use-data-fetching-7f3cvh
[suspense component]: https://react.dev/reference/react/Suspense
[error boundary]: https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary
[use docs]: https://react.dev/reference/react/use
Loading

0 comments on commit d808a5d

Please sign in to comment.