En esta página
Fetching de datos y Suspense
Estrategias para obtener datos en Vue
Hay tres momentos principales para obtener datos en un componente Vue:
- En
onMounted()— el patrón más común para datos iniciales - Con
watchEffect()— cuando los datos dependen de estado reactivo - Con
async setup— para componentes que necesitan datos antes de renderizar
Patrón onMounted — el más común
<script setup lang="ts">
import { ref, onMounted } from 'vue'
interface Post {
id: number
title: string
body: string
}
const posts = ref<Post[]>([])
const cargando = ref(false)
const error = ref<string | null>(null)
onMounted(async () => {
cargando.value = true
try {
const res = await fetch('https://jsonplaceholder.typicode.com/posts?_limit=10')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
posts.value = await res.json() as Post[]
} catch (e) {
error.value = e instanceof Error ? e.message : 'Error al cargar'
} finally {
cargando.value = false
}
})
</script>
<template>
<div>
<div v-if="cargando" role="status" aria-live="polite">
Cargando posts...
</div>
<div v-else-if="error" role="alert">
Error: {{ error }}
</div>
<ul v-else>
<li v-for="post in posts" :key="post.id">{{ post.title }}</li>
</ul>
</div>
</template>Composable useFetch con AbortController
Un useFetch robusto debe poder cancelar solicitudes en vuelo:
// composables/useFetch.ts
import { ref, watchEffect, toValue, onUnmounted, type MaybeRefOrGetter } from 'vue'
export function useFetch<T>(url: MaybeRefOrGetter<string>) {
const datos = ref<T | null>(null)
const cargando = ref(false)
const error = ref<string | null>(null)
let controller: AbortController | null = null
const stop = watchEffect(async () => {
// Cancelar la solicitud anterior si existe
controller?.abort()
controller = new AbortController()
const urlActual = toValue(url)
if (!urlActual) return
cargando.value = true
error.value = null
datos.value = null
try {
const res = await fetch(urlActual, { signal: controller.signal })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
datos.value = await res.json() as T
} catch (e) {
if (e instanceof Error && e.name !== 'AbortError') {
error.value = e.message
}
} finally {
cargando.value = false
}
})
// Limpiar al desmontar
onUnmounted(() => {
stop()
controller?.abort()
})
return { datos, cargando, error }
}Paginación reactiva con useFetch
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useFetch } from '@/composables/useFetch'
interface Usuario {
id: number
name: string
email: string
}
const pagina = ref(1)
const limite = ref(10)
const url = computed(
() => `https://jsonplaceholder.typicode.com/users?_page=${pagina.value}&_limit=${limite.value}`
)
const { datos: usuarios, cargando, error } = useFetch<Usuario[]>(url)
</script>
<template>
<div>
<p v-if="cargando">Cargando...</p>
<p v-else-if="error" role="alert">{{ error }}</p>
<ul v-else>
<li v-for="u in usuarios" :key="u.id">{{ u.name }}</li>
</ul>
<nav aria-label="Paginación">
<button type="button" :disabled="pagina <= 1" @click="pagina--">Anterior</button>
<span>Página {{ pagina }}</span>
<button type="button" @click="pagina++">Siguiente</button>
</nav>
</div>
</template>Suspense — async setup()
El componente <Suspense> permite escribir componentes con async setup (o await directo en script setup):
<!-- ProductoDetalle.vue — usa await directamente -->
<script setup lang="ts">
interface Producto {
id: number
nombre: string
precio: number
descripcion: string
}
const { id } = defineProps<{ id: number }>()
// ¡await directo en script setup! Suspense lo maneja
const res = await fetch(`/api/productos/${id}`)
if (!res.ok) throw new Error('Producto no encontrado')
const producto = await res.json() as Producto
</script>El componente padre debe envolverlo en <Suspense>:
<template>
<Suspense timeout="0">
<template #default>
<ProductoDetalle :id="productoId" />
</template>
<template #fallback>
<SkeletonProducto />
</template>
</Suspense>
</template>ErrorBoundary con onErrorCaptured
Captura errores lanzados desde componentes hijos (incluyendo los de async setup):
<!-- ErrorBoundary.vue -->
<script setup lang="ts">
import { ref, onErrorCaptured } from 'vue'
const error = ref<Error | null>(null)
onErrorCaptured((err) => {
error.value = err
return false // Evitar que el error se propague más arriba
})
</script>
<template>
<div v-if="error" role="alert">
<h2>Algo salió mal</h2>
<p>{{ error.message }}</p>
<button type="button" @click="error = null">Reintentar</button>
</div>
<slot v-else />
</template>Combinado con Suspense:
<template>
<ErrorBoundary>
<Suspense>
<template #default>
<ComponenteAsincrono />
</template>
<template #fallback>
<Skeleton />
</template>
</Suspense>
</ErrorBoundary>
</template>Estados de carga con Skeleton Loaders
En lugar de un spinner genérico, los skeleton loaders mejoran la experiencia percibida:
<template>
<div v-if="cargando" class="skeleton-card" aria-busy="true" aria-label="Cargando...">
<div class="skeleton-avatar"></div>
<div class="skeleton-lines">
<div class="skeleton-line" style="width: 70%"></div>
<div class="skeleton-line" style="width: 50%"></div>
</div>
</div>
<TarjetaUsuario v-else :usuario="usuario" />
</template>
<style scoped>
.skeleton-line {
height: 1rem;
background: linear-gradient(90deg, #e2e8f0 25%, #f1f5f9 50%, #e2e8f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 0.25rem;
margin-bottom: 0.5rem;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
</style>Refetch manual — patrón de recarga
A veces necesitas un botón "Recargar":
export function useFetchManual<T>(url: string) {
const datos = ref<T | null>(null)
const cargando = ref(false)
const error = ref<string | null>(null)
async function cargar(): Promise<void> {
cargando.value = true
error.value = null
try {
const res = await fetch(url)
datos.value = await res.json() as T
} catch (e) {
error.value = e instanceof Error ? e.message : 'Error'
} finally {
cargando.value = false
}
}
return { datos, cargando, error, cargar }
}<script setup lang="ts">
const { datos, cargando, error, cargar } = useFetchManual<Datos[]>('/api/datos')
onMounted(cargar)
</script>
<template>
<button type="button" @click="cargar" :disabled="cargando">
{{ cargando ? 'Cargando...' : '↺ Recargar' }}
</button>
</template>Práctica
- Lista con paginación: Usa el composable
useFetchpara cargar posts de JSONPlaceholder con paginación. Muestra un skeleton loader mientras carga y un botón de reintento cuando hay error. - Async setup con Suspense: Crea un componente
DetallePost.vueque useasync setuppara cargar los detalles de un post. Envuélvelo en<Suspense>con un skeleton de fallback y unErrorBoundary. - Cache simple: Extiende
useFetchpara implementar un cache en memoria: si la misma URL ya fue cargada, devuelve los datos del cache instantáneamente sin hacer otra petición de red.
En la siguiente lección aprenderemos a añadir vida a la interfaz con transiciones y animaciones.
Suspense es experimental pero estable — úsalo con cuidado
El componente Suspense está marcado como experimental en Vue 3, pero es seguro usarlo en producción. La API puede cambiar en futuras versiones menores. Nuxt.js lo usa extensivamente con excelentes resultados. Asegúrate de siempre envolver Suspense en un ErrorBoundary con onErrorCaptured para capturar errores de los componentes asíncronos.
AbortController — cancela fetch al desmontar
Cuando el componente se desmonta mientras hay una petición en curso, el callback del fetch puede intentar actualizar un ref que ya no existe. Usa AbortController con el signal de fetch y onUnmounted para cancelar la petición: const controller = new AbortController(); fetch(url, { signal: controller.signal }); onUnmounted(() => controller.abort()).
No hagas await en el nivel raíz sin Suspense
Si usas await en script setup, el componente padre DEBE envolverlo en Suspense, de lo contrario Vue lanzará una advertencia y el componente no se renderizará hasta que la promesa resuelva (bloqueando el render). Suspense es el mecanismo correcto para manejar este caso.
import { ref, shallowRef, type Ref } from 'vue'
type MetodoHttp = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
interface OpcionesApi<TBody> {
metodo?: MetodoHttp
cuerpo?: TBody
cabeceras?: Record<string, string>
}
interface EstadoApi<T> {
datos: Ref<T | null>
cargando: Ref<boolean>
error: Ref<string | null>
ejecutar: () => Promise<void>
}
export function useApi<T, TBody = unknown>(
url: string,
opciones: OpcionesApi<TBody> = {}
): EstadoApi<T> {
const datos = shallowRef<T | null>(null)
const cargando = ref(false)
const error = ref<string | null>(null)
async function ejecutar(): Promise<void> {
cargando.value = true
error.value = null
try {
const init: RequestInit = {
method: opciones.metodo ?? 'GET',
headers: {
'Content-Type': 'application/json',
...opciones.cabeceras,
},
}
if (opciones.cuerpo !== undefined) {
init.body = JSON.stringify(opciones.cuerpo)
}
const res = await fetch(url, init)
if (!res.ok) {
const texto = await res.text()
throw new Error(`${res.status}: ${texto}`)
}
datos.value = await res.json() as T
} catch (e) {
error.value = e instanceof Error ? e.message : 'Error desconocido'
datos.value = null
} finally {
cargando.value = false
}
}
return { datos, cargando, error, ejecutar }
}
<script setup lang="ts">
// async setup — Vue esperará a que la promesa resuelva antes de renderizar
interface Perfil {
id: number
nombre: string
email: string
avatar: string
}
const { id } = defineProps<{ id: number }>()
// Await directo en setup — funciona con <Suspense>
const res = await fetch(`https://jsonplaceholder.typicode.com/users/${id}`)
if (!res.ok) throw new Error(`Usuario ${id} no encontrado`)
const perfil = await res.json() as Perfil
</script>
<template>
<article class="perfil">
<img :src="perfil.avatar || `https://ui-avatars.com/api/?name=${perfil.nombre}`"
:alt="`Avatar de ${perfil.nombre}`" />
<h2>{{ perfil.nombre }}</h2>
<p>{{ perfil.email }}</p>
</article>
</template>
Inicia sesión para guardar tu progreso