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:
We can have a single, organized system that loads everything all at once:
But also gives us fine-grained control so we can achieve something in-between, if needed:
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.
Without Suspense, each component has to handle its loading state individually:
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.
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.
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:
async
setup
function — returning a Promise
or using top-level await
with script setup
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.
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:
There’s something else interesting going on here, and I want to take a short detour to explain it, if you’ll allow me.
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.
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:
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.
Instead of using a single spinner, placeholder components can often provide a better experience:
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%;
}
}
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.
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!
© All rights reserved. Made with ❤️ by BitterBrains, Inc.