Skip to content

What's Suspense For Anyways?

Posted on:July 7, 2024

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.