On this page

Data fetching, async setup, and Suspense in Vue 3

12 min read TextCh. 4 — State and Data

Data fetching strategies in Vue

Vue 3 offers several strategies for fetching data, each with different trade-offs:

  1. Top-level await in <script setup> — Simple, clean, uses <Suspense>
  2. watchEffect / watch with fetch — Reactive, re-fetches when deps change
  3. Composable (useFetch) — Reusable, type-safe, handles loading/error state
  4. Pinia action — For data shared across multiple components
  5. 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

  1. Async post detail: Create a PostDetail.vue with top-level await that fetches from https://jsonplaceholder.typicode.com/posts/:id. Wrap it in <Suspense> with a skeleton fallback and an error handler.
  2. Reactive search: Build a search page with an <input> bound to a query ref. Use watchEffect with AbortController to fetch results from a public API as the user types (debounce with a 300ms delay).
  3. Error boundary: Create a reusable <ErrorBoundary> component using onErrorCaptured. 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.

Use :key to re-trigger Suspense
When props change and you want the async component to re-fetch, add :key="someReactiveProp" to the async child inside <Suspense>. Changing the key destroys and recreates the component, triggering async setup again.
Error handling with Suspense
Suspense does not catch errors by default — they propagate up. Listen to the @error event on <Suspense> or wrap the async component in an error boundary (onErrorCaptured lifecycle hook) to catch and display errors gracefully.
useFetch composable for complex cases
Top-level await in <script setup> is clean but inflexible — you cannot reactively re-fetch when props change without a :key. For reactive fetching (where the URL depends on reactive data), use a watchEffect-based useFetch composable instead.
<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>