En esta página

Fetching de datos y Suspense

12 min lectura TextoCap. 4 — Estado y datos

Estrategias para obtener datos en Vue

Hay tres momentos principales para obtener datos en un componente Vue:

  1. En onMounted() — el patrón más común para datos iniciales
  2. Con watchEffect() — cuando los datos dependen de estado reactivo
  3. 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

  1. Lista con paginación: Usa el composable useFetch para cargar posts de JSONPlaceholder con paginación. Muestra un skeleton loader mientras carga y un botón de reintento cuando hay error.
  2. Async setup con Suspense: Crea un componente DetallePost.vue que use async setup para cargar los detalles de un post. Envuélvelo en <Suspense> con un skeleton de fallback y un ErrorBoundary.
  3. Cache simple: Extiende useFetch para 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.
typescript
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>