Suspense
is a powerful primitive but a very misunderstood one. Even in writing this article I don’t feel super qualified on this topic but I’ll try to explain it the best I can.
Suspense
isn’t in too many frameworks but it is in Solid and React which are the two I mainly use. I initially started with the concept in React but feel it clicked more once I moved to Solid and watched some of Ryan Carniato’s streams.
how suspense works
The first thing to understand is that there is a difference between async and non-async values. When you use createSignal
you are creating a synchronous value, whereas when you use createAsync
or createResource
you are creating an async value which will resolve in time. For React users an async value would be using something like use()
to unwrap a promise or an async server component.
Suspense
works well with async values, and Show
works well with sync values.
Let’s look at a sync example using createSignal
:
export default function Home() {
const [name, setName] = createSignal("");
return (
<>
<p>
<strong>Show (signal)</strong>
</p>
<button onClick={() => setName("Brenley")}>Set Name</button>
<Show when={name()} fallback={<p>Loading...</p>}>
<p>Name: {name()}</p>
</Show>
<p>
<strong>Suspense (signal)</strong>
</p>
<Suspense fallback={<p>Loading...</p>}>
<p>Name: {name()}</p>
</Suspense>
</>
);
}
How would you expect the above to work? I think the Show
component is fairly straightforward. Basically we see the fallback
of Loading...
until we click the button when we see Name: Brenley
. None of the inner children of Show
are ran until the name()
signal becomes a truthy value.
Now what about the Suspense
component? You might expect a similar outcome but you’d be incorrect. The fallback
is never triggered because we aren’t reading an async value. We are instead reading a signal that always has a value even if it’s an empty string. The children are ran with an empty name()
signal so we just get Name:
Then when we hit the button we get the name shown as expected.
Switching to using an async value changes things. Let’s see how!
const getUser = cache(async () => {
"use server";
await new Promise(r => setTimeout(r, 2000));
return {
name: "Brenley",
};
}, "get-user");
export default function Home() {
const user = createAsync(() => getUser());
return (
<>
<p>
<strong>Show (async)</strong>
</p>
<Show when={user()?.name} fallback={<p>Loading...</p>}>
<p>Name: {user()?.name}</p>
</Show>
</>
);
}
In this case we actually don’t see our loading indicator. This is because an unresolved async value bubbles up to the nearest suspense. In the case of SolidStart there is a Suspense
above this which is why we just get an empty screen for the two seconds until it loads.
If we use Suspense
we get our Loading...
fallback as expected.
export default function Home() {
const user = createAsync(() => getUser());
return (
<>
<p>
<strong>Suspense (async)</strong>
</p>
<Suspense fallback={<p>Loading...</p>}>
<p>Name: {user()?.name}</p>
</Suspense>
</>
);
}
A key thing to note here is the power that out of order streaming together with Suspense
has. The server can render everything outside the Suspense
and the fallback synchronously and send back the HTML. Then when the user()
promise resolves it can stream back the rest of the content. This allows you to show an initial shell much faster! Also, if my understanding is correct it actually forks this work in the Suspense
which renders the Name:
part and then commits it back to the main branch once the user()?.name
resolves.
a glorified loading indicator?
As you can see it is much more than just a loading indicator. It also gets more powerful once you read multiple async’s within the Suspense
as it shows the fallback until all the async’s have resolved. Imagine having to do this in a Show
component.
<Show when={user() && profileData() && tweets()}></Show>
a quick thought experiment
In the below case will the console.log
run right away or after the two second wait?
export default function Home() {
const user = createAsync(() => getUser());
return (
<>
<p>
<strong>Suspense (async)</strong>
</p>
<Suspense fallback={<p>Loading...</p>}>
<Name user={user()} />
</Suspense>
</>
);
}
function Name(props) {
console.log("running name");
return <h1>{props.user?.name}</h1>;
}
How would the below be different?
export default function Home() {
const user = createAsync(() => getUser());
return (
<>
<p>
<strong>Suspense (async)</strong>
</p>
<Show when={user()} fallback={<p>Loading...</p>}>
<Name user={user()} />
</Show>
</>
);
}
function Name(props) {
console.log("running name");
return <h1>{props.user?.name}</h1>;
}
Turns out in the Suspense
case the console.log
runs immediately where in the Show
component it runs after the two seconds.
what’s next?
In Solid 2.0 there is some exploration around colorless async. This is due to one thing that’s a bit annoying with the current Suspense
, which is null checks. For example:
function Name(props) {
console.log("running name");
return <h1>{props.user?.name}</h1>;
}
In theory we know props.user
should exist when this runs since its under a Suspense
boundary that will show a fallback.
Colorless async would give us the ability to not have these null checks.
Conclusion
Hopefully this helps showcase a bit of how Suspense
works, and where the future is headed. If you want to know more about Suspense
, I would suggest watching this stream which goes more in depth. You can also visit the Solid docs, or this Future Frontend talk.