Skip to content

Improve User Experience in Vue 3 with Suspense

This article was written over 18 months ago and may contain information that is out of date. Some content may be relevant but please refer to the relevant official documentation or available resources for the latest information.

Introduction

When building applications that leverage the internet, it's important to remember that not every user will have the same connection speed that developers have. A developer working on a local machine isn't going to have the same experience as an end user in a coffee shop, or even at home. It is important to remember that some users may think that parts of your application are broken, simply because the internet connection isn't fast enough!

While we can't control internet speeds, we can plan accordingly and prevent users from experiencing a broken application. Luckily, Vue 3 provides a new way to handle situations like this, called Suspense. "Suspense" is a new built-in component in Vue that we can wrap around another component needing to perform an asynchronous action before it can render. The implementation of Suspense in Vue is very similar to React Suspense. If the component inside <Suspense></Suspense> has an async setup() method, then a fallback is presented to the user until it is completed.

An example of the below code can be found on CodeSandbox.

<Suspense> Component

Let's explore a basic example using Suspense. We will build a basic application that utilizes the Pokemon API to fetch a list of berries and display them in a dropdown. To start, we have two files, App.vue and Berries.vue:

Berries.vue

<template>
  <h1 class="text-3xl pb-2">Select a Berry</h1>
  <select v-model="selectedBerry" class="px-4 py-2 w-40 shadow">
    <option value="" disabled>Select...</option>
    <option
      v-for="berry in berries.results"
      :key="berry.url"
      :value="berry.url"
    >
      {{ berry.name }}
    </option>
  </select>
</template>

<script lang="ts">
import { defineComponent, ref } from "vue";
import useBerries from "../hooks/useBerries";

export default defineComponent({
  name: "Berries",
  async setup() {
    const selectedBerry = ref("");
    const berries = await useBerries();

    return { berries, selectedBerry };
  },
});
</script>

App.vue

<template>
  <Suspense>
    <Berries />

    <template #fallback>
      <span class="text-3xl">Picking berries...</span>
    </template>
  </Suspense>
</template>

<script lang="ts">
import { defineComponent } from "vue";
import Berries from "./components/Berries.vue";

export default defineComponent({
  name: "App",
  components: {
    Berries,
  },
});
</script>

Let's take a look at these two files.

In Berries.vue, we have an async setup() method which returns two values: selectedBerry and berries. In this example, berries is created by the custom function useBerries, but under the hood, it's a regular HTTP request to the Pokemon API for a list of berries. The exact implementation of the API request is not important to our example. These values are then referenced in the template as normal. If any of this looks confusing, I would recommend checking out this article on using "ref" and "reactive" or this article explaining the Composition API in general.

In App.vue, we import Berries.vue and register it as normal. In the template, however, we use the <Suspense> compontent. Remember it's built into Vue 3, so there's no need to register it anywhere. Within Suspense, we have the Berries component and a template with the name fallback. Until the setup method in Berries.vue returns its promise, the content within the fallback will be displayed. Once setup has returned, the default template will be rendered (in this case, the Berries component).

Let's take this a step further, and add some functionality to our app. When a user selects a berry from the dropdown, we want to request the provided URL and display the flavor of the berry. To do this, we'll add another component, BerryDetails.vue, and use Suspense within Berries.vue.

Below are the changes:

BerryDetails.vue

<template>This berry is {{ berryFlavor.flavor.name }}.</template>

<script lang="ts">
import { defineComponent } from "vue";
import useBerryFlavor from "../hooks/useBerryFlavor";

export default defineComponent({
  name: "BerryDetails",
  props: {
    url: {
      type: String,
      required: true,
    },
  },
  async setup(props) {
    const berryFlavor = await useBerryFlavor(props.url);

    return {
      berryFlavor,
    };
  },
});
</script>

Berries.vue

<template>
  <h1 class="text-3xl pb-2">Select a Berry</h1>
  <select v-model="selectedBerry" class="px-4 py-2 w-40 shadow">
    <option value="" disabled>Select...</option>
    <option
      v-for="berry in berries.results"
      :key="berry.url"
      :value="berry.url"
    >
      {{ berry.name }}
    </option>
  </select>
  <!-- Add section to display BerryDetails -->
  <div class="w-3/5 m-auto p-5">
    <Suspense v-if="selectedBerry">
      <BerryDetails :url="selectedBerry" />

      <template #fallback> Fetching berry details... </template>
    </Suspense>
  </div>

</template>

<script lang="ts">
import { defineComponent, ref } from "vue";
import useBerries from "../hooks/useBerries";
import BerryDetails from "./BerryDetails.vue";

export default defineComponent({
  name: "Berries",
  async setup() {
    const selectedBerry = ref("");
    const berries = await useBerries();

    return { berries, selectedBerry };
  },
  components: {
    BerryDetails,
  },
});
</script>

BerryDetails.vue is very straightforward - it displays a string with the flavor of the berry. It also accepts a prop of url, which is a string. This URL is passed into a custom method, useBerryFlavor (again, the implementation of making an API request is not important to our example).

Berries.vue is updated to include a Suspense block, which looks very similar to the one in App.vue. The only difference here is that we are waiting for selectedBerry to be set to a value before rendering, which makes sense; if we don't have a selected berry, we don't want to make an API request.

Error Handling

Great! Our application is up and running, and any slowness will be represented to the user so they know that the application is working. Right? Well, almost. What if one of these API requests throws an error? We need to communicate this to the user, rather than leaving the application suspended forever. In this case, Vue 3 provides us with a hook called onErrorCaptured, which we can use to capture the error and update the display. Let's take a look at our updated Berries.vue component:

Berries.vue

<template>
  <h1 class="text-3xl pb-2">Select a Berry</h1>
  <select v-model="selectedBerry" class="px-4 py-2 w-40 shadow">
    <option value="" disabled>Select...</option>
    <option
      v-for="berry in berries.results"
      :key="berry.url"
      :value="berry.url"
    >
      {{ berry.name }}
    </option>
  </select>
  <div class="w-3/5 m-auto p-5">
    <!-- Added error handling block -->
    <div v-if="error">Oh, snap! The berries are all gone!</div>
    <Suspense v-else-if="selectedBerry">
      <template #default>
        <BerryDetails :url="selectedBerry" :key="selectedBerry" />
      </template>
      <template #fallback> Fetching berry details... </template>
    </Suspense>
  </div>
</template>

<script lang="ts">
import { defineComponent, ref, onErrorCaptured, watch } from "vue";
import useBerries from "../hooks/useBerries";
import BerryDetails from "./BerryDetails.vue";

export default defineComponent({
  name: "Berries",
  async setup() {
    const selectedBerry = ref("");
    // Added error ref
    const error = ref();

    // Added onErrorCaptured lifecycle hook
    onErrorCaptured((e) => {
      error.value = e;
      return true;
    });

    // Reset error when selectedBerry is updated
    watch(selectedBerry, () => (error.value = null));

    const berries = await useBerries();

    return { berries, selectedBerry, error };
  },
  components: {
    BerryDetails,
  },
});
</script>

We did three things in this component:

  1. We added a variable named error, which is a ref.
  2. We added the lifecycle hook onErrorCaptured, which will trigger whenever an error is captured from a child component. In this case, if the API request fails, we will catch the error, and update our display accordingly.
  3. We added a watch method on selectedBerry to reset the error whenever a new berry is selected.

Alternatively, this could be handled with a try/catch in BerryDetails.vue. In that case, the child component would need to handle the error, rather than let it bubble up to the parent, which is suspending render.

Conclusion

Suspense provides a built-in method to handle use cases that involve fetching data from an API, or performing some other asynchronous action. Rather than having to write custom logic, Vue 3 provides developers with the tools to build user-friendly applications and experiences. Fetching or loading data is a common task for single-page applications, and it is important that users are informed that something is happening behind the scenes. Next time you find yourself fetching data, consider whether the Suspense API is a good fit for the interface you are building.

An example of the code presented above can be found on CodeSandbox.

Let's innovate together!

We're ready to be your trusted technical partners in your digital innovation journey.

Whether it's modernization or custom software solutions, our team of experts can guide you through best practices and how to build scalable, performant software that lasts.

Prefer email? hi@thisdot.co