Home / Blog / Vue Suspense — Everything You Need to Know
Vue Suspense — Everything You Need to Know

Vue Suspense — Everything You Need to Know

Michael Thiessen
Michael Thiessen
Updated: June 6th 2022

Suspense is not what you think.

Yes, it helps us deal with async components.

But it’s so much more than that.

Suspense allows us to coordinate loading states across our application, including all deeply nested components.

Instead of having a popcorn UI with loading spinners everywhere and components suddenly popping into place:

uncoordinated.gif

We can have a single, organized system that loads everything all at once:

coordinated.gif

But also gives us fine-grained control so we can achieve something in-between, if needed:

nested.gif

In this article we’ll learn a lot about Suspense — what it is, what it isn’t, and how to use it.

First, we’ll take a closer look at these popcorn UIs. Then, we’ll look at how we can fix them using Suspense. After that, we’ll look at how we can gain finer control by nesting Suspense throughout our application. Lastly, we’ll take a brief look at using placeholders to amp up our UI.

This article is based off of a series of working demos, so you can follow along, tweak, and mess around with the code as you wish.

Popcorn UI — Before Suspense

Without Suspense, each component has to handle its loading state individually:

uncoordinated.gif

This can lead to some pretty janky UX, with multiple loading spinners and content popping into the screen as though you’re making popcorn. It’s just not very tasty to snack on.

Although you could create your own abstraction to handle these loading states, it’s a lot more difficult than just using Suspense (you’ll see how in a moment). Having a single spot that manages your loading state is a lot more maintainable than each component doing its own thing.

In our demo app we use the BeforeSuspense component to simulate a component that handles the loading state internally. I’ve named it BeforeSuspense because we’ll be refactoring it to become a WithSuspense component once we implement Suspense:

<template>
    <div class="async-component" :class="!loading && 'loaded'">
      <Spinner v-if="loading" />
      <slot />
    </div>
</template>
<script setup>
import { ref } from 'vue'
import Spinner from './Spinner.vue'

const loading = ref(true)
const { time } = defineProps({
  time: {
    type: Number,
    default: 2000
  }
})

setTimeout(() => (loading.value = false), time)
</script>

We initially set loading to true, so we show the spinner. Then, when the setTimeout completes, we set loading to false, hiding the spinner and making the component background green.

In this component we also include a slot so we can nest BeforeSuspense components within each other.

This is important for this demo, because the whole point of Suspense is to coordinate loading states across deeply nested hierarchies of components. Those slots let us show that off:

<template>
    <button @click="reload">Reload page</button>
    <BeforeSuspense time="3000">
      <BeforeSuspense time="2000" />
      <BeforeSuspense time="1000">
        <BeforeSuspense time="500" />
        <BeforeSuspense time="4000" />
      </BeforeSuspense>
    </BeforeSuspense>
</template>

Nothing too fancy there. Just some nested components with different time values passed to them.

Let’s see how we can improve on this popcorn UI by using the Suspense component.

Coordinated Loading with Suspense

Now we’ll use Suspense to take that hot mess and turn it into a better UX.

But first we need to quickly go over what Suspense actually is.

Suspense Basics

Here is the basic structure of a Suspense component:

<Suspense>
  <!-- Async component here -->

  <template #fallback>
      <!-- Sync loading state component here -->
  </template>
</Suspense>

To use Suspense, you put the asynchronous component into the default slot and your fallback loading state into the fallback slot.

An asynchronous component is one of two things:

  1. A component with an async setup function — returning a Promise or using top-level await with script setup
  2. A component that is loaded asynchronously by using defineAsyncComponent

Either way, we end up with a Promise that starts out unresolved, and is then eventually resolved.

While that Promise is unresolved, the Suspense component will show the fallback slot. Then, when the Promise does resolve, it will show the async component in the default slot.

Note: there is no error handling here. At first I thought there was and maybe you did too, but it’s a common misunderstanding of Suspense. I wish I knew what causes this confusion. We can use the onErrorCaptured hook to catch errors, but that is a separate feature from Suspense.

Now that we understand Suspense a bit better, let’s return to our demo app.

Managing Async Dependencies

In order to allow Suspense to manage our loading state, we first need to convert our BeforeSuspense component into an async component.

We’ll end up with this WithSuspense component:

<template>
  <div class="async-component loaded">
    <!-- We don't need a spinner here since loading is handled at the root -->
    <slot />
  </div>
</template>
<script setup>
const { time } = defineProps({
  time: {
    type: Number,
    required: true
  }
})

// Add in a delay to simulate loading data
await new Promise(resolve => {
  setTimeout(() => {
    resolve()
  }, time)
})
</script>

We’ve removed the Spinner for the loading state entirely, because this component no longer has a loading state.

Because this is an async component, the setup function will not return until it is finished loading (”loading” in our case is just simulated by the setTimeout). The component is only mounted after the setup function completes. So unlike the BeforeSuspense component, the WithSuspense component isn’t mounted until everything is finished loading.

This is true of any async component regardless of how it’s used. It will not render anything until the setup function returns (if synchronous) or resolves (if asynchronous).

So now we have our WithSuspense component, we still need to refactor our main App component to use this component inside of a Suspense component:

<template>
  <button @click="reload">Reload page</button>
  <Suspense>
    <WithSuspense :time="2000">
      <WithSuspense :time="1500" />
      <WithSuspense :time="1200">
        <WithSuspense :time="1000" />
        <WithSuspense :time="5000" />
      </WithSuspense>
    </WithSuspense>

    <template #fallback>
      <Spinner />
    </template>
  </Suspense>
</template>

We have the same basic structure as we had before, but this time it’s in the default slot of the Suspense component. We’ve also added in the fallback slot that renders our Spinner component during loading.

In the demo you’ll see that it shows the loading spinner until all of the components have finished loading. Only then does it show the now-fully-loaded component tree:

coordinated.gif

There’s something else interesting going on here, and I want to take a short detour to explain it, if you’ll allow me.

The Async Waterfall

If you’re paying close attention you’ll notice that these components are not loaded in parallel as you might expect.

The total time to load is not based on the slowest component (5 seconds). Instead, the time is much longer. This is because Vue will only begin mounting the children once the parent async component has fully resolved.

You can test this yourself by putting logs into the WithSuspense component. One at the beginning of the setup to track mounting, and one right before we call resolve.

In our initial example using BeforeSuspense components, the entire component tree is mounted without waiting, and all “async” operations kicked off in parallel. This means that Suspense could possibly affect performance by introducing this async waterfall. So please keep this in mind.

Okay, so now we’re able to show a loading spinner for the entire app.

Let’s see how we can fine tune our Suspense set up and achieve something in between all or nothing.

Nesting Suspense to Isolate Sub Trees

There’s something that really bothers me about what we have so far:

There is one deeply nested component here that takes 5 whole seconds to load, blocking the entire UI even though most of it finishes loading far sooner.

But there is a solution for us 😅

By nesting a second Suspense component further down, we can show the rest of the app even as we wait for this component to finish loading:

<template>
  <button @click="reload">Reload page</button>
  <Suspense>
    <WithSuspense :time="2000">
      <WithSuspense :time="1500" />
      <WithSuspense :time="1200">
        <WithSuspense :time="1000" />

                <!-- Nest a second Suspense component -->
        <Suspense>
          <WithSuspense :time="5000" />
          <template #fallback>
            <Spinner />
          </template>
        </Suspense>
      </WithSuspense>
    </WithSuspense>

    <template #fallback>
      <Spinner />
    </template>
  </Suspense>
</template>

Wrapping it in a second Suspense component isolates it from the rest of the app. The Suspense component itself is a synchronous component, so it’s mounted as soon as it’s parent is mounted.

Then it will show it’s own fallback content until the 5 seconds are up:

nested.gif

By doing this we can isolate slower loading parts of the app and decrease our time to first interaction. There may be instances where this is necessary, especially if you need to avoid the async waterfall.

It also makes sense from a feature perspective. Each feature or “section” of your app can be wrapped in it’s own Suspense component, so each feature loads as a single logical unit.

Of course, if you wrap every component in Suspense, we’ll be back where we started. However, it’s good to know that Suspense isn’t all or nothing. We can choose to batch our loading states in whatever way makes the most sense.

We’re basically done here, but I wanted to show you one more thing.

We’ll end this article with one more way we can spice up our Suspense components.

Using Suspense with Placeholders

Instead of using a single spinner, placeholder components can often provide a better experience:

placeholderrrrrr.gif

They prime the user for the content that will be shown, and give them a sense of what to expect before it happens. A spinner can’t achieve that. It doesn’t set any expectations or create any interest.

All to say — they’re snazzy and look cool. So let’s refactor the demo app to use placeholders:

<template>
  <button @click="reload">Reload page</button>
  <Suspense>
    <WithSuspense :time="2000">
      <WithSuspense :time="1500" />
      <WithSuspense :time="1200">
        <WithSuspense :time="1000" />

        <Suspense>
          <WithSuspense :time="5000" />
          <template #fallback>
            <Placeholder />
          </template>
        </Suspense>
      </WithSuspense>
    </WithSuspense>
    <template #fallback>
            <!-- Replicate the shape of the actual data -->
      <Placeholder>
        <Placeholder />
        <Placeholder>
          <Placeholder />
          <Placeholder />
        </Placeholder>
      </Placeholder>
    </template>
  </Suspense>
</template>

We’ve arranged these Placeholder components and styled them so they look exactly like the WithSuspense components. This provides a seamless transition between loading and loaded states.

In our demo the Placeholder component gives us a CSS animation on the background to create a pulsating effect:

.fast-gradient {
  background: linear-gradient(
    to right,
    rgba(255, 255, 255, 0.1),
    rgba(255, 255, 255, 0.4)
  );
  background-size: 200% 200%;
  animation: gradient 2s ease-in-out infinite;
}

@keyframes gradient {
  0% {
    background-position: 0% 50%;
  }
  50% {
    background-position: 100% 50%;
  }
  100% {
    background-position: 0% 50%;
  }
}

Conclusion

Popcorn loading states are very noticeable and can hurt the experience of your website.

Luckily for us, Suspense is a great new feature that gives us lots of options for coordinating loading states in our Vue applications.

However, at the time of writing, Suspense is still considered experimental, so proceed with caution. Refer to the docs for the most up-to-date info on it’s status.

Start learning Vue.js for free

Michael Thiessen
Michael Thiessen

Comments

Latest Vue School Articles

From Vue.js Options API to Composition API: Is it Worth it?

From Vue.js Options API to Composition API: Is it Worth it?

Explore the technicalities of transitioning from Options API to Composition API in Vue.js. Discover if migrating your app is worth the effort in our detailed guide
Mostafa Said
Mostafa Said
What’s New in Nuxt 4

What’s New in Nuxt 4

Have anxiety about a new major version of Nuxt coming out? Worried about a big migration project? Don’t worry about it, a peaceful and easy upgrade is literally one of the features of Nuxt version 4.
Daniel Kelly
Daniel Kelly

Our goal is to be the number one source of Vue.js knowledge for all skill levels. We offer the knowledge of our industry leaders through awesome video courses for a ridiculously low price.

More than 200.000 users have already joined us. You are welcome too!

Follow us on Social

© All rights reserved. Made with ❤️ by BitterBrains, Inc.