Tema
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 reintentarUsaremos 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.comEnviar 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.jsjs
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
- Agrega a la vista de detalle del recetario una sección de "Recetas similares" que muestre 3 recetas del mismo
mealType(excluyendo la actual). - Crea un composable
useLocalStorage(clave, inicial)que devuelva unrefsincronizado conlocalStorage, 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
AbortControllerenonCleanup. - Centraliza las llamadas en un cliente
api.jsy usaVITE_API_URL; nunca pongas secretos en el frontend.