On this page
Data fetching, async setup, and Suspense in Vue 3
Data fetching strategies in Vue
Vue 3 offers several strategies for fetching data, each with different trade-offs:
- Top-level
awaitin<script setup>— Simple, clean, uses<Suspense> watchEffect/watchwithfetch— Reactive, re-fetches when deps change- Composable (
useFetch) — Reusable, type-safe, handles loading/error state - Pinia action — For data shared across multiple components
- Vue Query / Tanstack Query for Vue — For caching, polling, and background sync
The best choice depends on whether the data needs to be reactive, shared, or cached.
Top-level await in script setup
The <script setup> block supports top-level await. A component with top-level await becomes an async component that must be wrapped in <Suspense>:
<script setup lang="ts">
interface Post {
id: number
title: string
body: string
}
// This suspends the component until the await resolves
const res = await fetch('https://jsonplaceholder.typicode.com/posts/1')
const post: Post = await res.json()
</script>
<template>
<article>
<h1>{{ post.title }}</h1>
<p>{{ post.body }}</p>
</article>
</template>The component only renders after post is available — no v-if loading checks needed in the template.
Suspense
<Suspense> is a built-in Vue component that orchestrates async child components. It shows a fallback slot while any descendant async component is resolving:
<Suspense>
<template #default>
<AsyncDashboard /> <!-- Has top-level await -->
</template>
<template #fallback>
<LoadingSpinner />
</template>
</Suspense>Suspense events
<Suspense
@pending="onPending" <!-- Async resolution started -->
@resolve="onResolve" <!-- All async children resolved -->
@fallback="onFallback" <!-- Fallback slot is visible -->
@error="onError" <!-- An async child threw an error -->
>Nested Suspense
When nested async components resolve at different times, each can have its own <Suspense>. The parent waits for all descendants by default.
Reactive fetching with watchEffect
Top-level await does not re-fetch when props or route params change (without a :key). For reactive fetching, use watchEffect:
<script setup lang="ts">
import { ref, watchEffect } from 'vue'
const props = defineProps<{ postId: number }>()
interface Post { id: number; title: string; body: string }
const post = ref<Post | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
watchEffect(async (onCleanup) => {
const controller = new AbortController()
onCleanup(() => controller.abort())
loading.value = true
error.value = null
try {
const res = await fetch(
`https://jsonplaceholder.typicode.com/posts/${props.postId}`,
{ signal: controller.signal }
)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
post.value = await res.json()
} catch (err) {
if (!(err instanceof DOMException && err.name === 'AbortError')) {
error.value = err instanceof Error ? err.message : 'Unknown error'
}
} finally {
loading.value = false
}
})
</script>When props.postId changes, watchEffect automatically re-runs, aborts the previous request, and fetches the new post.
Async components
Beyond top-level await, Vue supports lazily-loaded async components via defineAsyncComponent():
import { defineAsyncComponent } from 'vue'
// Minimal — Vite handles code splitting automatically
const HeavyChart = defineAsyncComponent(
() => import('./components/HeavyChart.vue')
)
// With full options
const HeavyChart = defineAsyncComponent({
loader: () => import('./components/HeavyChart.vue'),
loadingComponent: SpinnerComponent,
errorComponent: ErrorComponent,
delay: 200, // Wait 200ms before showing loading component
timeout: 10_000, // Show error after 10s
})Async components work well with <Suspense> and route-level lazy loading.
Error handling with onErrorCaptured
Errors thrown inside async setup propagate to the nearest error boundary. Implement one with onErrorCaptured:
<!-- ErrorBoundary.vue -->
<script setup lang="ts">
import { ref, onErrorCaptured } from 'vue'
const error = ref<Error | null>(null)
onErrorCaptured((err) => {
error.value = err
return false // Prevent propagation to parent
})
</script>
<template>
<div v-if="error" role="alert">
<p>Something went wrong: {{ error.message }}</p>
<button type="button" @click="error = null">Retry</button>
</div>
<slot v-else />
</template>Usage:
<ErrorBoundary>
<Suspense>
<AsyncUserProfile :user-id="userId" />
<template #fallback><LoadingSpinner /></template>
</Suspense>
</ErrorBoundary>Fetching on mount vs watch
For data that depends on route params, choose between onMounted (one-time) and watch/watchEffect (reactive):
// One-time fetch on mount
import { onMounted, ref } from 'vue'
const data = ref(null)
onMounted(async () => {
const res = await fetch('/api/data')
data.value = await res.json()
})// Reactive fetch — re-runs when params change
import { watchEffect, ref } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const data = ref(null)
watchEffect(async () => {
const res = await fetch(`/api/data?page=${route.query.page}`)
data.value = await res.json()
})Skeleton loading pattern
Skeleton screens provide better perceived performance than spinners:
<!-- Shown in Suspense #fallback -->
<template>
<div class="skeleton-card" aria-busy="true" aria-label="Loading content">
<div class="skeleton-line wide" />
<div class="skeleton-line" />
<div class="skeleton-line short" />
</div>
</template>
<style scoped>
.skeleton-line {
height: 1rem;
background: linear-gradient(90deg, #e2e8f0 25%, #f8fafc 50%, #e2e8f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 0.25rem;
margin-block: 0.5rem;
}
@keyframes shimmer {
from { background-position: 200% 0; }
to { background-position: -200% 0; }
}
</style>Practice
- Async post detail: Create a
PostDetail.vuewith top-levelawaitthat fetches fromhttps://jsonplaceholder.typicode.com/posts/:id. Wrap it in<Suspense>with a skeleton fallback and an error handler. - Reactive search: Build a search page with an
<input>bound to aqueryref. UsewatchEffectwithAbortControllerto fetch results from a public API as the user types (debounce with a 300ms delay). - Error boundary: Create a reusable
<ErrorBoundary>component usingonErrorCaptured. Test it by throwing an error in an async child component.
In the next lesson, we will bring Vue components to life with transitions and animations — Vue's built-in transition system and CSS/JavaScript animation hooks.
<script setup lang="ts">
// Async setup — this component is "async" from Vue's perspective
// It can be wrapped in <Suspense> by its parent
interface User {
id: number
name: string
email: string
company: { name: string }
}
const props = defineProps<{ userId: number }>()
// Top-level await is allowed in <script setup>
// The component suspends until this resolves
const res = await fetch(
`https://jsonplaceholder.typicode.com/users/${props.userId}`
)
if (!res.ok) throw new Error(`HTTP ${res.status}: Failed to load user`)
const user: User = await res.json()
</script>
<template>
<article class="profile">
<h2>{{ user.name }}</h2>
<p>{{ user.email }}</p>
<p>Works at: {{ user.company.name }}</p>
</article>
</template>
<style scoped>
.profile {
padding: 1rem;
border: 1px solid #e2e8f0;
border-radius: 0.5rem;
}
</style>
Sign in to track your progress