Suspense

Intermedio
Vuejs
Vuejs
Actualizado: 27/03/2026

Qué es Suspense

Suspense es un componente built-in de Vue que gestiona la carga de componentes asíncronos. Permite mostrar contenido de reserva (fallback) mientras los componentes hijos con dependencias asíncronas se resuelven. Esto simplifica el manejo de estados de carga que anteriormente requería lógica manual con variables booleanas isLoading.

Suspense trabaja con dos tipos de dependencias asíncronas:

  • Componentes con async setup() o top-level await en <script setup>
  • Componentes asíncronos definidos con defineAsyncComponent()

Cuando detecta una dependencia asíncrona pendiente, muestra el contenido del slot #fallback hasta que todas las dependencias se resuelvan, momento en el que muestra el slot #default.

Slots default y fallback

Suspense utiliza dos slots con nombre:

  • #default: El contenido principal que contiene los componentes asíncronos
  • #fallback: El contenido mostrado durante la carga
<script setup lang="ts">
import AsyncDashboard from './AsyncDashboard.vue'
</script>

<template>
  <Suspense>
    <template #default>
      <AsyncDashboard />
    </template>
    <template #fallback>
      <div class="loading">
        <div class="spinner"></div>
        <p>Cargando dashboard...</p>
      </div>
    </template>
  </Suspense>
</template>

<style scoped>
.loading {
  display: flex;
  flex-direction: column;
  align-items: center;
  padding: 40px;
}

.spinner {
  width: 40px;
  height: 40px;
  border: 4px solid #eee;
  border-top-color: #3498db;
  border-radius: 50%;
  animation: spin 0.8s linear infinite;
}

@keyframes spin {
  to { transform: rotate(360deg); }
}
</style>

Componentes con async setup

Un componente que usa async setup() o top-level await en <script setup> se convierte automáticamente en una dependencia asíncrona de Suspense:

<!-- AsyncUserProfile.vue -->
<script setup lang="ts">
interface User {
  id: number
  name: string
  email: string
  avatar: string
  role: string
}

// top-level await convierte este componente en asíncrono
const response = await fetch('/api/user/profile')
const user: User = await response.json()
</script>

<template>
  <div class="user-profile">
    <img :src="user.avatar" :alt="user.name" />
    <h2>{{ user.name }}</h2>
    <p>{{ user.email }}</p>
    <span class="badge">{{ user.role }}</span>
  </div>
</template>

Cuando este componente se usa dentro de Suspense, Vue espera a que las peticiones fetch se completen antes de renderizar el componente. Mientras tanto, se muestra el fallback.

Múltiples await en un componente

Un componente puede tener múltiples operaciones asíncronas. Suspense espera a que todas se resuelvan:

<!-- AsyncDashboard.vue -->
<script setup lang="ts">
interface Stats {
  totalUsers: number
  activeUsers: number
  revenue: number
}

interface RecentOrder {
  id: number
  product: string
  amount: number
  date: string
}

const [statsResponse, ordersResponse] = await Promise.all([
  fetch('/api/stats'),
  fetch('/api/orders/recent')
])

const stats: Stats = await statsResponse.json()
const orders: RecentOrder[] = await ordersResponse.json()
</script>

<template>
  <div class="dashboard">
    <div class="stats-grid">
      <div class="stat-card">
        <h3>Usuarios totales</h3>
        <p>{{ stats.totalUsers }}</p>
      </div>
      <div class="stat-card">
        <h3>Usuarios activos</h3>
        <p>{{ stats.activeUsers }}</p>
      </div>
      <div class="stat-card">
        <h3>Ingresos</h3>
        <p>{{ stats.revenue }} €</p>
      </div>
    </div>
    <h3>Pedidos recientes</h3>
    <ul>
      <li v-for="order in orders" :key="order.id">
        {{ order.product }} - {{ order.amount }} € ({{ order.date }})
      </li>
    </ul>
  </div>
</template>

Usar Promise.all ejecuta ambas peticiones en paralelo, reduciendo el tiempo total de carga.

Suspense con composables de data fetching

Un patrón común es encapsular la lógica de fetching en un composable y usar top-level await para integrarlo con Suspense:

// composables/useApi.ts
export async function useApi<T>(url: string): Promise<T> {
  const response = await fetch(url)
  if (!response.ok) {
    throw new Error(`Error ${response.status}: ${response.statusText}`)
  }
  return response.json()
}
<!-- ProductList.vue -->
<script setup lang="ts">
import { useApi } from '@/composables/useApi'

interface Product {
  id: number
  name: string
  price: number
  category: string
}

const products = await useApi<Product[]>('/api/products')
</script>

<template>
  <ul>
    <li v-for="product in products" :key="product.id">
      <strong>{{ product.name }}</strong> - {{ product.price }} €
      <span class="category">{{ product.category }}</span>
    </li>
  </ul>
</template>

Manejo de errores con onErrorCaptured

Suspense no maneja errores por defecto. Si un componente asíncrono lanza un error, este se propaga hacia arriba. Para capturarlo, se usa el hook onErrorCaptured en un componente padre:

<!-- AsyncBoundary.vue -->
<script setup lang="ts">
import { ref, onErrorCaptured } from 'vue'

const error = ref<Error | null>(null)

onErrorCaptured((err: Error) => {
  error.value = err
  return false // Evita que el error se propague más arriba
})

function retry() {
  error.value = null
}
</script>

<template>
  <div v-if="error" class="error-state">
    <h3>Ha ocurrido un error</h3>
    <p>{{ error.message }}</p>
    <button @click="retry">Reintentar</button>
  </div>
  <Suspense v-else>
    <template #default>
      <slot />
    </template>
    <template #fallback>
      <slot name="loading">
        <div class="loading">Cargando...</div>
      </slot>
    </template>
  </Suspense>
</template>

Uso del boundary:

<script setup lang="ts">
import AsyncBoundary from './AsyncBoundary.vue'
import AsyncUserProfile from './AsyncUserProfile.vue'
</script>

<template>
  <AsyncBoundary>
    <AsyncUserProfile />
    <template #loading>
      <div class="skeleton-profile">
        <div class="skeleton-avatar"></div>
        <div class="skeleton-text"></div>
        <div class="skeleton-text short"></div>
      </div>
    </template>
  </AsyncBoundary>
</template>

Al hacer clic en "Reintentar", error se resetea a null, lo que remonta el Suspense y sus componentes hijos, volviendo a ejecutar el setup asíncrono.

Suspense anidado

Se pueden anidar múltiples Suspense para controlar la granularidad de los estados de carga:

<template>
  <Suspense>
    <template #default>
      <div class="layout">
        <AsyncNavbar />

        <main>
          <Suspense>
            <template #default>
              <AsyncPageContent />
            </template>
            <template #fallback>
              <div>Cargando contenido de la página...</div>
            </template>
          </Suspense>
        </main>

        <AsyncFooter />
      </div>
    </template>
    <template #fallback>
      <div>Cargando estructura de la aplicación...</div>
    </template>
  </Suspense>
</template>

El Suspense exterior espera a que AsyncNavbar y AsyncFooter se carguen. El Suspense interior gestiona AsyncPageContent de forma independiente, permitiendo que la estructura de la página se muestre antes que el contenido principal.

Eventos de Suspense

Suspense emite tres eventos que permiten reaccionar a los cambios de estado:

  • @pending: Se dispara cuando entra en estado de espera (hay dependencias pendientes)
  • @resolve: Se dispara cuando todas las dependencias se han resuelto
  • @fallback: Se dispara cuando se muestra el contenido del fallback
<script setup lang="ts">
function onPending() {
  console.log('Suspense: esperando componentes asíncronos...')
}

function onResolve() {
  console.log('Suspense: todos los componentes cargados')
}

function onFallback() {
  console.log('Suspense: mostrando fallback')
}
</script>

<template>
  <Suspense
    @pending="onPending"
    @resolve="onResolve"
    @fallback="onFallback"
  >
    <template #default>
      <AsyncContent />
    </template>
    <template #fallback>
      <LoadingSkeleton />
    </template>
  </Suspense>
</template>

Estos eventos son útiles para instrumentación, analytics o para coordinar animaciones con el estado de carga.

Ejemplo práctico completo

<!-- App.vue -->
<script setup lang="ts">
import { ref, onErrorCaptured } from 'vue'
import AsyncUserCard from './AsyncUserCard.vue'

const error = ref<Error | null>(null)
const key = ref(0)

onErrorCaptured((err: Error) => {
  error.value = err
  return false
})

function retry() {
  error.value = null
  key.value++
}
</script>

<template>
  <div class="app">
    <h1>Panel de usuario</h1>

    <div v-if="error" class="error-card">
      <p>No se pudo cargar el perfil: {{ error.message }}</p>
      <button @click="retry">Reintentar</button>
    </div>

    <Suspense v-else :key="key">
      <template #default>
        <AsyncUserCard />
      </template>
      <template #fallback>
        <div class="skeleton-card">
          <div class="skeleton-circle"></div>
          <div class="skeleton-line"></div>
          <div class="skeleton-line short"></div>
        </div>
      </template>
    </Suspense>
  </div>
</template>
<!-- AsyncUserCard.vue -->
<script setup lang="ts">
interface UserProfile {
  id: number
  name: string
  email: string
  bio: string
  postsCount: number
  followersCount: number
}

const response = await fetch('/api/user/me')
if (!response.ok) {
  throw new Error('No se pudo obtener el perfil del usuario')
}
const user: UserProfile = await response.json()
</script>

<template>
  <div class="user-card">
    <h2>{{ user.name }}</h2>
    <p class="email">{{ user.email }}</p>
    <p class="bio">{{ user.bio }}</p>
    <div class="stats">
      <span>{{ user.postsCount }} publicaciones</span>
      <span>{{ user.followersCount }} seguidores</span>
    </div>
  </div>
</template>

Este ejemplo combina Suspense con onErrorCaptured y un mecanismo de reintento. Al cambiar la prop :key, Vue desmonta y vuelve a montar el Suspense y sus hijos, ejecutando de nuevo las peticiones asíncronas.

Alan Sastre - Autor del tutorial

Alan Sastre

Ingeniero de Software y formador, CEO en CertiDevs

Ingeniero de software especializado en Full Stack y en Inteligencia Artificial. Como CEO de CertiDevs, Vuejs es una de sus áreas de expertise. Con más de 15 años programando, 6K seguidores en LinkedIn y experiencia como formador, Alan se dedica a crear contenido educativo de calidad para desarrolladores de todos los niveles.

Más tutoriales de Vuejs

Explora más contenido relacionado con Vuejs y continúa aprendiendo con nuestros tutoriales gratuitos.

Aprendizajes de esta lección

Cursos que incluyen esta lección

Esta lección forma parte de los siguientes cursos estructurados con rutas de aprendizaje