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-levelawaiten<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
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