Skip to content

Consumir APIs ​

Casi toda app real muestra datos de un servidor. En esta lección aprenderás a pedir datos con fetch, manejar los estados de carga, error y éxito, crear composables reutilizables (useFetch), cancelar peticiones, enviar datos (POST, PUT, DELETE) y, al final, construirás un mini proyecto: un recetario con búsqueda, filtros y detalle.

Concepto ​

Una petición es asíncrona: tarda y puede fallar. Tu interfaz debe contemplar siempre tres estados:

txt
          ┌──► éxito  → muestra los datos
cargando ─┤
          └──► error  → muestra un mensaje y la opción de reintentar

Usaremos APIs públicas de prueba: DummyJSON (productos, recetas, usuarios) y JSONPlaceholder (posts, usuarios).

fetch en un componente ​

vue
<script setup>
import { ref, onMounted } from 'vue'

const usuarios = ref([])
const cargando = ref(true)
const error = ref(null)

async function cargar() {
  cargando.value = true
  error.value = null
  try {
    const res = await fetch('https://jsonplaceholder.typicode.com/users')
    // fetch NO lanza error con 404 o 500: hay que revisarlo
    if (!res.ok) throw new Error(`Error ${res.status}`)
    usuarios.value = await res.json()
  } catch (e) {
    error.value = e.message
  } finally {
    cargando.value = false
  }
}

onMounted(cargar)
</script>

<template>
  <p v-if="cargando">Cargando usuarios…</p>
  <div v-else-if="error" role="alert">
    No se pudieron cargar: {{ error }}
    <button @click="cargar">Reintentar</button>
  </div>
  <ul v-else>
    <li v-for="u in usuarios" :key="u.id">
      <strong>{{ u.name }}</strong> — {{ u.email }}
    </li>
  </ul>
</template>

Error común

Olvidar revisar res.ok. fetch sólo falla si no hay red; un 404 o un 500 se consideran "exitosos" y acabarías mostrando el JSON de error como si fueran datos.

Composables: lógica reutilizable ​

Un composable es una función que usa la Composition API (ref, watch, onMounted...) y encapsula lógica con estado para reutilizarla en varios componentes. Por convención su nombre empieza con use. Es el equivalente a los custom hooks de React.

useFetch ​

js
// src/composables/useFetch.js
import { ref, shallowRef, watchEffect, toValue } from 'vue'

/**
 * Pide datos a una URL y los vuelve a pedir si la URL cambia.
 * @param {string | import('vue').Ref<string|null> | (() => string|null)} url
 */
export function useFetch(url, opciones = {}) {
  const datos = shallowRef(null)  // shallowRef: no hace reactivo cada objeto interno (más eficiente)
  const error = ref(null)
  const cargando = ref(false)

  async function ejecutar(signal) {
    const urlActual = toValue(url) // acepta string, ref o función
    if (!urlActual) return          // permite "pausar" pasando null
    cargando.value = true
    error.value = null
    try {
      const res = await fetch(urlActual, { ...opciones, signal })
      if (!res.ok) throw new Error(`Error ${res.status}`)
      datos.value = await res.json()
    } catch (e) {
      if (e.name !== 'AbortError') error.value = e
    } finally {
      if (!signal?.aborted) cargando.value = false
    }
  }

  // Se re-ejecuta automáticamente cuando cambia la URL (si es reactiva)
  watchEffect((onCleanup) => {
    const controlador = new AbortController()
    onCleanup(() => controlador.abort()) // cancela la petición anterior
    ejecutar(controlador.signal)
  })

  return { datos, error, cargando, recargar: () => ejecutar() }
}

Uso:

vue
<script setup>
import { ref, computed } from 'vue'
import { useFetch } from '@/composables/useFetch'

const userId = ref(1)
// URL reactiva: al cambiar userId, se vuelve a pedir y se cancela la anterior
const url = computed(() => `https://jsonplaceholder.typicode.com/posts?userId=${userId.value}`)
const { datos: posts, cargando, error, recargar } = useFetch(url)
</script>

<template>
  <select v-model.number="userId">
    <option v-for="n in 10" :key="n" :value="n">Usuario {{ n }}</option>
  </select>

  <p v-if="cargando">Cargando…</p>
  <p v-else-if="error">Error: {{ error.message }} <button @click="recargar">Reintentar</button></p>
  <ul v-else-if="posts">
    <li v-for="p in posts" :key="p.id">{{ p.title }}</li>
  </ul>
</template>

useDebounce ​

js
// src/composables/useDebounce.js
import { ref, watch } from 'vue'

export function useDebounce(fuente, retraso = 400) {
  const valor = ref(fuente.value)
  let temporizador
  watch(fuente, (nuevo) => {
    clearTimeout(temporizador)
    temporizador = setTimeout(() => (valor.value = nuevo), retraso)
  })
  return valor
}

VueUse

VueUse es una colección de más de 200 composables listos y probados: useFetch, useDebounceFn, useLocalStorage, useIntersectionObserver, useDark... Antes de escribir uno, revisa si ya existe ahí (npm i @vueuse/core).

Un cliente de API centralizado ​

Para no repetir la URL base, los headers y el manejo de errores:

js
// src/lib/api.js
const BASE_URL = import.meta.env.VITE_API_URL ?? 'https://dummyjson.com'

export class ApiError extends Error {
  constructor(status, mensaje, datos) {
    super(mensaje)
    this.status = status
    this.datos = datos
  }
}

export async function api(ruta, { body, headers, ...opciones } = {}) {
  const res = await fetch(`${BASE_URL}${ruta}`, {
    ...opciones,
    headers: { ...(body && { 'Content-Type': 'application/json' }), ...headers },
    body: body ? JSON.stringify(body) : undefined
    // credentials: 'include', // si tu backend usa cookies de sesión
  })
  const datos = res.status === 204 ? null : await res.json().catch(() => null)
  if (!res.ok) throw new ApiError(res.status, datos?.message ?? datos?.error ?? `Error ${res.status}`, datos)
  return datos
}

api.get = (ruta, op) => api(ruta, { ...op, method: 'GET' })
api.post = (ruta, body, op) => api(ruta, { ...op, method: 'POST', body })
api.put = (ruta, body, op) => api(ruta, { ...op, method: 'PUT', body })
api.delete = (ruta, op) => api(ruta, { ...op, method: 'DELETE' })
env
# .env (sólo las variables VITE_ llegan al navegador; son públicas)
VITE_API_URL=https://dummyjson.com

Enviar datos y actualización optimista ​

vue
<script setup>
import { ref, onMounted } from 'vue'
import { api } from '@/lib/api'

const tareas = ref([])
const nueva = ref('')
const enviando = ref(false)

onMounted(async () => {
  const datos = await api.get('/todos?limit=5')
  tareas.value = datos.todos
})

async function crear() {
  if (!nueva.value.trim()) return
  enviando.value = true
  try {
    const creada = await api.post('/todos/add', { todo: nueva.value.trim(), completed: false, userId: 1 })
    tareas.value.unshift({ ...creada, id: Date.now() }) // DummyJSON simula la creación
    nueva.value = ''
  } catch (e) {
    alert(`No se pudo crear: ${e.message}`)
  } finally {
    enviando.value = false
  }
}

async function eliminar(tarea) {
  const respaldo = [...tareas.value]
  // Optimista: la quitamos ya y revertimos si falla
  tareas.value = tareas.value.filter((t) => t.id !== tarea.id)
  try {
    await api.delete(`/todos/${tarea.id}`)
  } catch {
    tareas.value = respaldo
    alert('No se pudo eliminar')
  }
}
</script>

<template>
  <form @submit.prevent="crear">
    <input v-model="nueva" placeholder="Nueva tarea">
    <button :disabled="enviando">{{ enviando ? 'Guardando…' : 'Agregar' }}</button>
  </form>
  <ul>
    <li v-for="t in tareas" :key="t.id">
      {{ t.todo }} <button @click="eliminar(t)">✕</button>
    </li>
  </ul>
</template>

Seguridad

Nunca pongas claves secretas (de pagos, de IA, de bases de datos) en variables VITE_: cualquiera puede verlas en el JavaScript. Esas llamadas deben pasar por tu backend (curso de Node) o por las rutas de servidor de Nuxt.

Mini proyecto: recetario ​

Una app que lista recetas de DummyJSON con búsqueda (debounce), filtro por tipo de comida, paginación con "cargar más" y una vista de detalle. Usa composables, Vue Router y componentes.

txt
src/
├── composables/
│   ├── useRecetas.js
│   └── useDebounce.js
├── components/
│   └── TarjetaReceta.vue
├── views/
│   ├── RecetasView.vue
│   └── RecetaView.vue
└── router/index.js
js
import { ref, watch } from 'vue'

const BASE = 'https://dummyjson.com/recipes'
const LIMITE = 9
const CAMPOS = 'name,image,mealType,difficulty,prepTimeMinutes,cookTimeMinutes,rating,cuisine'

// Lista con búsqueda, filtro y "cargar más"
export function useRecetas(busqueda, tipo) {
  const recetas = ref([])
  const total = ref(0)
  const cargando = ref(false)
  const error = ref(null)
  let controlador

  async function cargar({ reiniciar = false } = {}) {
    controlador?.abort()
    controlador = new AbortController()
    const skip = reiniciar ? 0 : recetas.value.length
    const q = busqueda.value.trim()

    let url = `${BASE}?limit=${LIMITE}&skip=${skip}&select=${CAMPOS}`
    if (q) url = `${BASE}/search?q=${encodeURIComponent(q)}&limit=${LIMITE}&skip=${skip}&select=${CAMPOS}`
    else if (tipo.value) url = `${BASE}/meal-type/${tipo.value}?limit=${LIMITE}&skip=${skip}&select=${CAMPOS}`

    cargando.value = true
    error.value = null
    try {
      const res = await fetch(url, { signal: controlador.signal })
      if (!res.ok) throw new Error(`Error ${res.status}`)
      const datos = await res.json()
      recetas.value = reiniciar ? datos.recipes : [...recetas.value, ...datos.recipes]
      total.value = datos.total
    } catch (e) {
      if (e.name !== 'AbortError') error.value = e.message
    } finally {
      cargando.value = false
    }
  }

  // Al cambiar búsqueda o tipo, empieza desde cero
  watch([busqueda, tipo], () => cargar({ reiniciar: true }), { immediate: true })

  return { recetas, total, cargando, error, cargarMas: () => cargar(), reintentar: () => cargar({ reiniciar: true }) }
}

// Detalle de una receta
export function useReceta(id) {
  const receta = ref(null)
  const cargando = ref(true)
  const error = ref(null)

  watch(
    id,
    async (valor) => {
      cargando.value = true
      error.value = null
      try {
        const res = await fetch(`${BASE}/${valor}`)
        if (!res.ok) throw new Error(res.status === 404 ? 'Receta no encontrada' : `Error ${res.status}`)
        receta.value = await res.json()
      } catch (e) {
        error.value = e.message
      } finally {
        cargando.value = false
      }
    },
    { immediate: true }
  )

  return { receta, cargando, error }
}
js
import { ref, watch } from 'vue'

export function useDebounce(fuente, retraso = 400) {
  const valor = ref(fuente.value)
  let t
  watch(fuente, (nuevo) => {
    clearTimeout(t)
    t = setTimeout(() => (valor.value = nuevo), retraso)
  })
  return valor
}
vue
<script setup>
const props = defineProps({ receta: { type: Object, required: true } })
const tiempo = props.receta.prepTimeMinutes + props.receta.cookTimeMinutes
</script>

<template>
  <RouterLink :to="{ name: 'receta', params: { id: receta.id } }" class="tarjeta">
    <img :src="receta.image" :alt="receta.name" loading="lazy">
    <div class="info">
      <h3>{{ receta.name }}</h3>
      <p>⏱ {{ tiempo }} min · {{ receta.difficulty }} · ⭐ {{ receta.rating }}</p>
      <p class="cocina">{{ receta.cuisine }}</p>
    </div>
  </RouterLink>
</template>

<style scoped>
.tarjeta { display: block; border-radius: 14px; overflow: hidden; background: white; box-shadow: 0 4px 16px rgb(0 0 0 / 0.06); color: inherit; text-decoration: none; transition: transform 0.2s; }
.tarjeta:hover { transform: translateY(-4px); }
img { width: 100%; aspect-ratio: 4 / 3; object-fit: cover; }
.info { padding: 0.75rem 1rem 1rem; }
h3 { margin: 0 0 0.25rem; font-size: 1.05rem; }
p { margin: 0; font-size: 0.85rem; color: #6b7280; }
.cocina { margin-top: 0.25rem; color: #42b883; font-weight: 600; }
</style>
vue
<script setup>
import { ref } from 'vue'
import { useDebounce } from '@/composables/useDebounce'
import { useRecetas } from '@/composables/useRecetas'
import TarjetaReceta from '@/components/TarjetaReceta.vue'

const texto = ref('')
const busqueda = useDebounce(texto, 400)
const tipo = ref('')
const tipos = ['Breakfast', 'Lunch', 'Dinner', 'Dessert', 'Snack']

const { recetas, total, cargando, error, cargarMas, reintentar } = useRecetas(busqueda, tipo)
</script>

<template>
  <section>
    <h1>🍳 Recetario</h1>

    <div class="filtros">
      <input v-model="texto" type="search" placeholder="Busca: pizza, tacos, curry…" aria-label="Buscar recetas">
      <select v-model="tipo" :disabled="Boolean(busqueda)" aria-label="Tipo de comida">
        <option value="">Todas</option>
        <option v-for="t in tipos" :key="t" :value="t">{{ t }}</option>
      </select>
    </div>

    <div v-if="error" role="alert" class="error">
      {{ error }} <button @click="reintentar">Reintentar</button>
    </div>

    <p v-if="!cargando && !recetas.length && !error">No encontramos recetas para "{{ busqueda }}".</p>

    <div class="grid">
      <TarjetaReceta v-for="r in recetas" :key="r.id" :receta="r" />
    </div>

    <p v-if="cargando" class="centro">Cargando…</p>
    <div v-else-if="recetas.length < total" class="centro">
      <button @click="cargarMas">Cargar más ({{ recetas.length }} de {{ total }})</button>
    </div>
  </section>
</template>

<style scoped>
.filtros { display: flex; gap: 0.75rem; flex-wrap: wrap; margin-bottom: 1.5rem; }
.filtros input { flex: 1 1 240px; padding: 0.6rem 0.9rem; border: 1px solid #d1d5db; border-radius: 10px; }
.filtros select { padding: 0.6rem; border-radius: 10px; border: 1px solid #d1d5db; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 1.25rem; }
.error { background: #fef2f2; color: #991b1b; padding: 0.75rem 1rem; border-radius: 10px; margin-bottom: 1rem; }
.centro { text-align: center; margin-top: 1.5rem; }
.centro button { padding: 0.6rem 1.5rem; border-radius: 99px; border: 2px solid #42b883; background: white; color: #42b883; font-weight: 600; cursor: pointer; }
</style>
vue
<script setup>
import { toRef } from 'vue'
import { useReceta } from '@/composables/useRecetas'

const props = defineProps({ id: { type: String, required: true } })
const { receta, cargando, error } = useReceta(toRef(props, 'id'))
</script>

<template>
  <p v-if="cargando">Cargando receta…</p>
  <p v-else-if="error">{{ error }}. <RouterLink to="/">Volver</RouterLink></p>

  <article v-else class="receta">
    <RouterLink to="/">← Todas las recetas</RouterLink>
    <h1>{{ receta.name }}</h1>
    <p class="meta">
      {{ receta.cuisine }} · {{ receta.difficulty }} · ⏱ {{ receta.prepTimeMinutes + receta.cookTimeMinutes }} min ·
      🍽 {{ receta.servings }} porciones · ⭐ {{ receta.rating }}
    </p>
    <img :src="receta.image" :alt="receta.name">

    <div class="columnas">
      <section>
        <h2>Ingredientes</h2>
        <ul>
          <li v-for="ing in receta.ingredients" :key="ing">{{ ing }}</li>
        </ul>
      </section>
      <section>
        <h2>Preparación</h2>
        <ol>
          <li v-for="(paso, i) in receta.instructions" :key="i">{{ paso }}</li>
        </ol>
      </section>
    </div>
  </article>
</template>

<style scoped>
.receta { max-width: 860px; margin: 0 auto; }
.meta { color: #6b7280; }
img { width: 100%; max-height: 380px; object-fit: cover; border-radius: 16px; }
.columnas { display: grid; grid-template-columns: 1fr 2fr; gap: 2rem; }
li { margin-bottom: 0.4rem; }
@media (max-width: 700px) { .columnas { grid-template-columns: 1fr; } }
</style>
js
import { createRouter, createWebHistory } from 'vue-router'
import RecetasView from '@/views/RecetasView.vue'

export default createRouter({
  history: createWebHistory(),
  routes: [
    { path: '/', name: 'recetas', component: RecetasView },
    { path: '/receta/:id', name: 'receta', component: () => import('@/views/RecetaView.vue'), props: true }
  ],
  scrollBehavior: (to, from, guardada) => guardada ?? { top: 0 }
})

Asegúrate de que App.vue tenga un <RouterView /> y de registrar el router en main.js.

¿Y TanStack Query para Vue?

Para apps con muchos datos del servidor, TanStack Query también existe para Vue (@tanstack/vue-query) con caché, reintentos y sincronización automáticos. Otra opción popular es Pinia Colada, del autor de Pinia. Los conceptos son los mismos que viste en Zustand y TanStack Query.

Ejercicios ​

  1. Agrega a la vista de detalle del recetario una sección de "Recetas similares" que muestre 3 recetas del mismo mealType (excluyendo la actual).
  2. Crea un composable useLocalStorage(clave, inicial) que devuelva un ref sincronizado con localStorage, y úsalo para guardar las recetas favoritas (con un botón ❤️ en cada tarjeta).
Ver solución
vue
<!-- 1. En RecetaView.vue, agrega: -->
<script setup>
import { ref, toRef, watch } from 'vue'
import { useReceta } from '@/composables/useRecetas'
import TarjetaReceta from '@/components/TarjetaReceta.vue'

const props = defineProps({ id: { type: String, required: true } })
const { receta, cargando, error } = useReceta(toRef(props, 'id'))
const similares = ref([])

watch(receta, async (r) => {
  if (!r) return
  const tipo = r.mealType?.[0]
  if (!tipo) return
  const datos = await (await fetch(`https://dummyjson.com/recipes/meal-type/${tipo}?limit=4`)).json()
  similares.value = datos.recipes.filter((x) => x.id !== r.id).slice(0, 3)
})
</script>

<!-- Y al final de la plantilla del artículo: -->
<!--
<section v-if="similares.length">
  <h2>Recetas similares</h2>
  <div class="grid-similares">
    <TarjetaReceta v-for="s in similares" :key="s.id" :receta="s" />
  </div>
</section>
-->
js
// 2. src/composables/useLocalStorage.js
import { ref, watch } from 'vue'

export function useLocalStorage(clave, inicial) {
  let valorInicial = inicial
  try {
    const guardado = localStorage.getItem(clave)
    if (guardado !== null) valorInicial = JSON.parse(guardado)
  } catch {
    // JSON inválido o almacenamiento no disponible
  }
  const valor = ref(valorInicial)
  watch(valor, (v) => localStorage.setItem(clave, JSON.stringify(v)), { deep: true })
  return valor
}
vue
<!-- En TarjetaReceta.vue -->
<script setup>
import { computed } from 'vue'
import { useLocalStorage } from '@/composables/useLocalStorage'

const props = defineProps({ receta: { type: Object, required: true } })
const favoritos = useLocalStorage('recetas-favoritas', [])
const esFavorita = computed(() => favoritos.value.includes(props.receta.id))

function alternar() {
  favoritos.value = esFavorita.value
    ? favoritos.value.filter((id) => id !== props.receta.id)
    : [...favoritos.value, props.receta.id]
}
</script>

<!-- En la plantilla, dentro de .info:
<button @click.prevent="alternar" :aria-pressed="esFavorita" aria-label="Favorita">
  {{ esFavorita ? '❤️' : '🤍' }}
</button>
-->

Nota: cada tarjeta crea su propio ref; si necesitas que todas se sincronicen al instante, mueve los favoritos a un store de Pinia.

Resumen ​

  • Maneja siempre los estados de carga, error y éxito, y revisa res.ok.
  • Los composables (useFetch, useDebounce, useRecetas) encapsulan lógica con estado y se reutilizan entre componentes.
  • Cancela peticiones obsoletas con AbortController en onCleanup.
  • Centraliza las llamadas en un cliente api.js y usa VITE_API_URL; nunca pongas secretos en el frontend.

Academia Web — aprende, copia y construye.