Data Fetching in Vue 3

Fotis Adamakis

--

Data fetching optimization is key for a performant web application. Vue helps by offering many different lifecycle hooks, but finding the best one for each specific type of call is not straightforward.

Let me help with some real-life scenarios.

Different Types of API Calls

Not all API calls are the same. There are two broad categories:

  1. Application data
  2. Page data

Each type should be handled differently.

Application Data

These are calls that affect the whole application and should be dispatched as soon as possible. Some examples are fetching Application Settings, Feature Toggles, or Authentication information.

A renderless component, provider component, a custom plugin or explicitly dispatching a call from main.js can all work. The last option is the most common and probably the easiest for someone new to the codebase to understand. We will use that.

An example of a simple composable that fetches the application settings and stores them for everyone else to use is the following:

// src/features/appSettings/composables/useAppSettings.ts
import { ref } from "vue";
import { fetchAppSettings } from "@/features/appSettings/api";

const appSettings = ref();

export function useAppSettings() {

async function fetch() {
const response = await fetchAppSettings();
appSettings.value = response;
}

return { appSettings, fetch };
}

Assume a similar composable for useToggles and useAuth

I’m skipping some repetitive stuff but you can always refer to this repo if you get lost.

We can now update our main application file with the API calls.

import { createApp } from 'vue'
import './style.css'
import App from './App.vue'

import { useAuth } from '@/features/auth/composables/useAuth'
import { useToggles } from '@/features/toggles/composables/useToggles';
import { useAppSettings } from '@/features/appSettings/composables/useAppSettings';

await useAuth().authenticate()
await useToggles().fetch()
await useAppSettings().fetch()

createApp(App).mount('#app')

Each composable is initiated and fetches the data immediately.

Notice that each call uses an await keyword. This means they will be done in sequence.

This will affect the initial page load since our application will wait for these calls to be fulfilled before rendering.

To make them execute in parallel we can wrap them in a promise.all.

import { createApp } from "vue";
import "./style.css";
import App from "./App.vue";

import { useAuth } from "@/features/auth/composables/useAuth";
import { useToggles } from "@/features/toggles/composables/useToggles";
import { useAppSettings } from "@/features/appSettings/composables/useAppSettings";

await useAuth().authenticate();
Promise.all([
await useToggles().fetch(),
await useAppSettings().fetch()
]);

createApp(App).mount("#app");

First, we authenticate and then fetch the toggles and application settings in parallel. The idea is that a toggle can be overridden for a specific user.

Your application logic will differ but having the flexibility to make calls in parallel is essential for a performant application.

If you are thinking of removing theawaitsto improve performance, it is not a good idea. Our application will render with the default values and then the UI will be updated to reflect the new Data causing a bad UX and probably bugs.

Of course, when the initialization logic gets complicated it can be moved to a separate file to keep the main application file cleaner.

Code and live example.

Page Data

Page-specific data can be fetched with the combination of a top-level await and Suspense.

A component can make an API call inside script setup and wait for the data to be available before rendering. We have abstracted the fetching logic inside a composable but notice the await keyword when calling it.

<script setup lang="ts">
import { ref } from "vue";
import useArticles from "@/features/articles/composables/useArticles";

const article = ref();
article.value = await useArticles().fetchLatestArticle();
</script>

<template>
<article v-html="article.description" />
</template>

On a page level, we need to wrap this component in a Suspense block and optionally provide a loading state.

<script setup lang="ts">
import Article from "@/features/articles/components/Article.vue";
import RelatedArticles from "@/features/articles/components/RelatedArticles.vue";
</script>

<template>
<Suspense>
<Article />
<template #fallback> Loading... </template>
</Suspense>
</template>

Code and live example.

Suspense is especially useful when managing multiple loading states.

Lastly a beforeEnter route navigation guard is available and can be used to run code before the page is shown.

const routes = [
{
path: '/users/:id',
component: UserDetails,
beforeEnter: (to, from, next) => {
// reject the navigation
return false
},
},
]

This has to be declared in the router level which is a bit inconvenient and doesn't have access to the component instance since it hasn't been created yet. We can still pass data to the component using the next callback.

const routes = [
{
path: '/users/:id',
component: UserDetails,
beforeEnter: (to, from, next) => {
next({
data: 'This will be available in the component'
})
},
},
]

Conclusion

To sum up, we have three options. Load essential application data right when the application starts (with independent calls made in parallel). Then fetch page-specific data with a top-level await&suspense or inside the beforeEnter guard depending if you want to show a loading state or not.
These optimizations will directly affect your core web vitals and more specifically your first and largest contentful paints. 🚀🚀🚀

--

--

« Senior Software Engineer · Author · International Speaker · Vue.js Athens Meetup Organizer »