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