With Vue 3, we received a new and quite special component called <Suspense />
. In a nutshell, it allows us to better control how and when our UI should render when the component tree includes asynchronous components. But what exactly does that mean?
Quick disclaimer
At the time of the writing of this article, Suspense
is still marked as an experimental feature and, according to the docs, “not guaranteed to reach stable status and the API may change before it does”.
The setup
In order to better understand the power of Suspense
, I have build a mock CMS where our users are able to see their company’s data (through some mock APIs). The main and only screen, or dashboard, looks like this once everything is loaded.
We have three main components, one for each of the columns.
📃 Employees.vue
<template>
<div>
<h2>Employees</h2>
<p v-if="loading">Loading...</p>
<ul v-else>
<li v-for="employee in employees" :key="employee.id">
{{ employee.firstName }} {{ employee.lastName }} - {{ employee.email }}
</li>
</ul>
</div>
</template>
<script setup>
import { get } from 'axios'
import { ref } from 'vue'
const employees = ref([])
const loading = ref(true)
get('https://hub.dummyapis.com/employee?noofRecords=10&idStarts=1001')
.then(({ data }) => {
loading.value = false
employees.value = data
})
</script>
📃 Products.vue
<template>
<div>
<h2>Products</h2>
<p v-if="loading">Loading...</p>
<ul v-else>
<li v-for="product in products" :key="product.id">
{{ product.name }} - {{ product.price }}
</li>
</ul>
</div>
</template>
<script setup>
import { get } from 'axios'
import { ref } from 'vue'
const products = ref([])
const loading = ref(true)
get('https://hub.dummyapis.com/products?noofRecords=10&idStarts=1001&currency=usd')
.then(({ data }) => {
loading.value = false
products.value = data
})
</script>
📃 SlowData.vue
<template>
<div>
<h2>Big data</h2>
<p v-if="loading">Loading...</p>
<p v-else>🐢</p>
</div>
</template>
<script setup>
import { get } from 'axios'
import { ref } from 'vue'
const loading = ref(true)
get('https://hub.dummyapis.com/delay?seconds=3')
.then(({ data }) => {
loading.value = false
})
</script>
The three components follow the same pattern: it fetches its own data from a demo API endpoint, toggles a loading flag off once its loaded, and then displays the data. Until the point where the data is fully loaded, a Loading
indicator appears instead.
The BigData.vue component is intentionally slow, taking a full 3 seconds to load. So whenever the user visits the page, they will see three unique loading texts. Two get replaced by data in a sort-of-fast fashion, and one takes quite a bit to load.
The problem
The above example is fully functional, however the UX is not the best. In some scenarios when building applications, having a handful of little components handling their own loading states and displaying 1, 2, 3+ loading spinners in the page can quickly get out of hand and pollute our design. Not to mention, if some of these APIs fail, then we need to start handling the possibility of having some sort of global error state.
Instead, it would be even better if for this case we could consolidate all the loading states into one, and display a single Loading...
flag.
💡 Advanced tip: If you’ve ever used Promise.all
this will feel very similar.
The solution
We are going to use Suspense
to solve our UX problem. It will allow us to “catch” (on a top level component) the fact that we have sub-components that are making network requests at creation (within setup
) and consolidate the loading flag into a single place.
To be able to do this, we first need to modify our components to actually be async. The way they are currently written uses JavaScript Promise’s then
method, which is not asynchronous. The then
block executes at a later point in time, and does not block the rest of the setup code from execution.
Another common way of writing this is to set your async calls within the onMounted
method, but again—this is not async.
For a component to actually be considered async, it needs to make an API call within the setup
method that blocks the execution of the script, by using the async/await
API.
Looking at these components’ code, you might notice something, though…
📃 Employees.vue
<template>
<div>
<h2>Employees</h2>
<ul>
<li v-for="employee in employees" :key="employee.id">
{{ employee.firstName }} {{ employee.lastName }} - {{ employee.email }}
</li>
</ul>
</div>
</template>
<script setup>
import { get } from 'axios'
import { ref } from 'vue'
const employees = ref([])
const { data } = await get('https://hub.dummyapis.com/employee?noofRecords=10&idStarts=1001')
employees.value = data
</script>
📃 Products.vue
<template>
<div>
<h2>Products</h2>
<ul>
<li v-for="product in products" :key="product.id">
{{ product.name }} - {{ product.price }}
</li>
</ul>
</div>
</template>
<script setup>
import { get } from 'axios'
import { ref } from 'vue'
const products = ref([])
const { data } = await get('https://hub.dummyapis.com/products?noofRecords=10&idStarts=1001&currency=usd')
products.value = data
</script>
📃 SlowData.vue
<template>
<div>
<h2>Big data</h2>
<p>🐢 {{stuff.length}}</p>
</div>
</template>
<script setup>
import { get } from 'axios'
const { data } = await get('https://hub.dummyapis.com/delay?seconds=3')
const stuff = data
</script>
All of the component example’s are using script setup
which allows us to directly use await
within it without having to declare async
at all. If you don’t want to use script setup
, or are unable to, and instead want to use a setup () {}
function, you must declare it as async.
<script>
export default {
async setup () {
await something()
}
}
</script>
Now that all of our components have been correctly setup (pun intended) to be async, we need to wrap them up in a Suspense
component on the parent.
Note that it does not need to be the immediate parent, this is a very important feature to remember.
📃 App.vue
<template>
<h1>Amazing CMS</h1>
<sub>So amaze ✨</sub>
<Suspense>
<main>
<Employees />
<Products />
<SlowData />
</main>
<template #fallback>
<div>Loading...</div>
</template>
</Suspense>
</template>
<script setup>
import Employees from './components/Employees.vue'
import Products from './components/Products.vue'
import SlowData from './components/SlowData.vue'
</script>
In our App.vue component, we now wrap our three components with a <Suspense />
component.
The first thing to note is that Suspense
will not allow for a multi-root default slot content, so I’ve chosen to wrap them up all here with a <main>
tag. If you forget, Vue will issue a clear warning asking you to make it a single root element.
The second and more important thing is that Suspense
provides us with a fallback
slot. Consider this slot to be the provisional view to load while the async components inside the default slot resolve.
In this case, I’ve chosen to simply set up the Loading...
text while everything gets loaded in the background. Of course, you are free to make the default view as simple or complicated as your application needs.
Handling errors
An important part of any application is to be able to handle errors when they occur. The Suspense
component itself does not provide an API to handle errors, but Vue does expose an onErrorCaptured
hook that we can use in our App.vue component to catch whenever an error happens.
📃 App.vue
<script setup>
import { onErrorCaptured } from 'vue'
import Employees from './components/Employees.vue'
import Products from './components/Products.vue'
import SlowData from './components/SlowData.vue'
onErrorCaptured((e, instance, info) => {
console.log(e, instance, info)
})
</script>
Note that this strategy is a little “too late” in being able to do anything in regards to a network call failing in one of your sub-components. The API details for onErrorCaptured in the documentation go into more detail about each of the parameters you receive, but in a nutshell you get:
- The error
- The pointer to the instance of the component that threw the error
- Information on the origin of the error (
setup
function, for example)
Theoretically, at this point you could use the instance
to call an exposed method to retry the call, or simply set the application in an error state and block the whole UI — it depends on your app’s particular needs.
A better and more recommendable approach would be to set error handling within the component itself, if your particular solution allows it.
For example, we could forcefully break our Products.vue component to demonstrate.
📃 Products.vue
<script setup>
import { get } from 'axios'
import { ref } from 'vue'
const products = ref([])
try {
const { data } = await get('nopenotanapi.com')
products.value = data
} catch (e) {
products.value = []
}
</script>
The try/catch
block will prevent the whole UX from being completely locked while gracefully setting the products ref to an empty array. The correct (and best) solution depends entirely on the scope of your application, and how you want to handle your errors!
Wrapping up
Vue 3’s Suspense component is definitely a very powerful tool that allows for clearer UX. With any luck, we can get to use it in its final non-experimental form soon, and I hope this article helped you get acquainted with how to implement it. For more explorations into Vue 3, you can take a Vue 3 Deep Dive with Evan You, or explore Vue 3 Reactivity source code and the Composition API in Vue Mastery’s courses.