Consumir una API
Las aplicaciones reales no tienen los datos escritos directamente en el código — los obtienen de APIs. Ya aprendiste fetch() y async/await en la sección de JavaScript. Ahora vas a ver cómo integrar llamadas a APIs dentro de componentes Vue usando los patrones que todo desarrollador Vue utiliza a diario.
fetch() dentro de onMounted()
El lugar natural para hacer una llamada a una API es dentro de onMounted() — el hook del ciclo de vida que se ejecuta cuando el componente ya está renderizado en el DOM. El patrón básico es: declarar refs para los datos, el estado de carga y los errores, y luego hacer el fetch cuando el componente se monta.
Vamos a usar la Rick and Morty API (https://rickandmortyapi.com/api/character) — una API pública gratuita perfecta para practicar. Devuelve personajes con nombre, imagen, estado y más.
<script setup>
import { ref, onMounted } from 'vue'
const personajes = ref([])
const loading = ref(true)
const error = ref(null)
onMounted(async () => {
try {
const respuesta = await fetch('https://rickandmortyapi.com/api/character')
if (!respuesta.ok) {
throw new Error(`Error HTTP: ${respuesta.status}`)
}
const datos = await respuesta.json()
personajes.value = datos.results
} catch (err) {
error.value = err.message
} finally {
loading.value = false
}
})
</script>
<template>
<div>
<h2>Personajes de Rick and Morty</h2>
<!-- Estado: cargando -->
<p v-if="loading">Cargando personajes...</p>
<!-- Estado: error -->
<p v-else-if="error" style="color: red">
Error: {{ error }}
</p>
<!-- Estado: datos cargados -->
<ul v-else>
<li v-for="personaje in personajes" :key="personaje.id">
{{ personaje.name }} — {{ personaje.status }}
</li>
</ul>
</div>
</template>
Fíjate en la estructura: tres refs (personajes, loading, error) y un try/catch/finally dentro de onMounted. Este es el patrón fundamental que usarás una y otra vez.
El patrón loading / error / data
Este patrón de tres estados es tan común que merece su propio nombre. Toda llamada a una API puede estar en uno de tres estados:
- Cargando — la petición está en curso
- Error — algo salió mal
- Datos listos — la respuesta llegó correctamente
En el template, usas v-if / v-else-if / v-else para mostrar la interfaz correcta según el estado:
<template>
<div>
<!-- 1. Cargando -->
<div v-if="loading" class="spinner">
Cargando...
</div>
<!-- 2. Error -->
<div v-else-if="error" class="error">
<p>Algo salió mal: {{ error }}</p>
<button @click="reintentar">Reintentar</button>
</div>
<!-- 3. Sin resultados -->
<div v-else-if="personajes.length === 0" class="vacio">
<p>No se encontraron personajes.</p>
</div>
<!-- 4. Datos listos -->
<div v-else class="grid">
<div v-for="personaje in personajes" :key="personaje.id" class="card">
<img :src="personaje.image" :alt="personaje.name">
<h3>{{ personaje.name }}</h3>
<span>{{ personaje.status }}</span>
</div>
</div>
</div>
</template>
Nota que también añadimos un cuarto estado: sin resultados. Esto es importante porque una respuesta vacía no es un error — simplemente no hay datos que mostrar.
Crear un composable: useApi()
Si cada componente que hace fetch repite las mismas tres refs y el mismo try/catch... estás duplicando código. La solución en Vue son los composables.
Un composable es una función que encapsula lógica reactiva para reutilizarla en múltiples componentes. La convención es que su nombre empieza con use — como useApi, useFetch, useUsers.
import { ref } from 'vue'
export function useApi(url) {
const data = ref(null)
const loading = ref(false)
const error = ref(null)
const fetchData = async () => {
loading.value = true
error.value = null
try {
const respuesta = await fetch(url)
if (!respuesta.ok) {
throw new Error(`Error HTTP: ${respuesta.status}`)
}
data.value = await respuesta.json()
} catch (err) {
error.value = err.message
} finally {
loading.value = false
}
}
return { data, loading, error, fetchData }
}
Ahora en cualquier componente puedes usarlo con una sola línea:
<script setup>
import { onMounted } from 'vue'
import { useApi } from './composables/useApi'
const { data, loading, error, fetchData } = useApi(
'https://rickandmortyapi.com/api/character'
)
onMounted(() => {
fetchData()
})
</script>
<template>
<div>
<p v-if="loading">Cargando...</p>
<p v-else-if="error" style="color: red">
Error: {{ error }}
</p>
<div v-else-if="data">
<div v-for="personaje in data.results" :key="personaje.id">
<img :src="personaje.image" :alt="personaje.name" width="100">
<p>{{ personaje.name }} — {{ personaje.status }}</p>
</div>
</div>
</div>
</template>
Este es EL patrón en Vue. Los composables reemplazaron a los mixins (de Vue 2) como la forma estándar de reutilizar lógica. Es simplemente una función que devuelve refs — limpia, testeable y fácil de entender.
Ejemplo práctico: búsqueda y filtrado
Vamos a construir algo más completo: una galería de personajes con un buscador que filtre los resultados en tiempo real usando computed.
<script setup>
import { ref, computed, onMounted } from 'vue'
const personajes = ref([])
const loading = ref(true)
const error = ref(null)
const busqueda = ref('')
onMounted(async () => {
try {
const respuesta = await fetch('https://rickandmortyapi.com/api/character')
const datos = await respuesta.json()
personajes.value = datos.results
} catch (err) {
error.value = err.message
} finally {
loading.value = false
}
})
// Filtrar personajes en el cliente según lo que escriba el usuario
const personajesFiltrados = computed(() => {
if (!busqueda.value) return personajes.value
const termino = busqueda.value.toLowerCase()
return personajes.value.filter(p =>
p.name.toLowerCase().includes(termino)
)
})
</script>
<template>
<div>
<h2>Galería de Personajes</h2>
<input
v-model="busqueda"
type="text"
placeholder="Buscar personaje..."
>
<p v-if="loading">Cargando personajes...</p>
<p v-else-if="error" style="color: red">{{ error }}</p>
<p v-else-if="personajesFiltrados.length === 0">
No se encontró ningún personaje con "{{ busqueda }}"
</p>
<div v-else class="grid">
<div
v-for="personaje in personajesFiltrados"
:key="personaje.id"
class="card"
>
<img :src="personaje.image" :alt="personaje.name">
<h3>{{ personaje.name }}</h3>
<span :class="personaje.status.toLowerCase()">
{{ personaje.status }}
</span>
</div>
</div>
</div>
</template>
<style scoped>
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 1rem;
}
.card {
text-align: center;
border: 1px solid #ddd;
border-radius: 8px;
padding: 1rem;
}
.card img {
width: 100%;
border-radius: 8px;
}
.alive { color: green; }
.dead { color: red; }
.unknown { color: gray; }
</style>
Aquí el filtrado ocurre en el cliente — tenemos todos los personajes en memoria y computed filtra la lista al instante mientras escribes. Esto es ideal cuando tienes una cantidad manejable de datos. Pero ¿qué pasa cuando necesitas buscar en el servidor?
URL reactiva y refetching con watch()
A veces necesitas que la petición a la API cambie según una acción del usuario — como cambiar de página, seleccionar una categoría o escribir en un buscador que consulta al servidor. Para esto combinas ref() con watch().
<script setup>
import { ref, watch, onMounted } from 'vue'
const personajes = ref([])
const loading = ref(true)
const error = ref(null)
const pagina = ref(1)
const totalPaginas = ref(1)
const fetchPersonajes = async () => {
loading.value = true
error.value = null
try {
const url = `https://rickandmortyapi.com/api/character?page=${pagina.value}`
const respuesta = await fetch(url)
if (!respuesta.ok) {
throw new Error(`Error HTTP: ${respuesta.status}`)
}
const datos = await respuesta.json()
personajes.value = datos.results
totalPaginas.value = datos.info.pages
} catch (err) {
error.value = err.message
} finally {
loading.value = false
}
}
// Cargar la primera página al montar
onMounted(() => {
fetchPersonajes()
})
// Cuando cambia la página, volver a hacer fetch
watch(pagina, () => {
fetchPersonajes()
})
</script>
<template>
<div>
<h2>Personajes (Página {{ pagina }} de {{ totalPaginas }})</h2>
<p v-if="loading">Cargando...</p>
<p v-else-if="error" style="color: red">{{ error }}</p>
<div v-else>
<div v-for="personaje in personajes" :key="personaje.id">
<img :src="personaje.image" :alt="personaje.name" width="80">
{{ personaje.name }}
</div>
<div class="paginacion">
<button
@click="pagina--"
:disabled="pagina <= 1"
>
← Anterior
</button>
<span>{{ pagina }} / {{ totalPaginas }}</span>
<button
@click="pagina++"
:disabled="pagina >= totalPaginas"
>
Siguiente →
</button>
</div>
</div>
</div>
</template>
El flujo es simple: cuando el usuario hace clic en "Siguiente" o "Anterior", pagina cambia. watch() detecta ese cambio y llama a fetchPersonajes() con la nueva URL. La interfaz se actualiza automáticamente.
El mismo patrón funciona para búsqueda en el servidor — la Rick and Morty API soporta el parámetro ?name=:
<script setup>
import { ref, watch } from 'vue'
const busqueda = ref('')
const resultados = ref([])
const loading = ref(false)
const error = ref(null)
const buscar = async () => {
if (!busqueda.value.trim()) {
resultados.value = []
return
}
loading.value = true
error.value = null
try {
const url = `https://rickandmortyapi.com/api/character?name=${busqueda.value}`
const respuesta = await fetch(url)
if (respuesta.status === 404) {
resultados.value = []
return
}
if (!respuesta.ok) {
throw new Error(`Error HTTP: ${respuesta.status}`)
}
const datos = await respuesta.json()
resultados.value = datos.results
} catch (err) {
error.value = err.message
} finally {
loading.value = false
}
}
// Esperar 300ms después de que el usuario deje de escribir
let timeout = null
watch(busqueda, () => {
clearTimeout(timeout)
timeout = setTimeout(() => buscar(), 300)
})
</script>
<template>
<div>
<input v-model="busqueda" placeholder="Buscar personaje en la API...">
<p v-if="loading">Buscando...</p>
<p v-else-if="error">{{ error }}</p>
<p v-else-if="busqueda && resultados.length === 0">
No se encontraron resultados para "{{ busqueda }}"
</p>
<div v-for="r in resultados" :key="r.id">
<img :src="r.image" :alt="r.name" width="60">
{{ r.name }}
</div>
</div>
</template>
Debounce: fíjate en el
setTimeoutde 300ms dentro delwatch. Esto evita hacer una petición HTTP por cada letra que el usuario escribe — espera a que deje de escribir durante 300 milisegundos antes de llamar a la API. Es una técnica fundamental cuando conectas inputs con peticiones al servidor.
Resumen
onMounted+fetch— el lugar para hacer llamadas a APIs cuando el componente se carga.- Patrón loading/error/data — tres refs (
data,loading,error) + try/catch/finally para manejar todos los estados de una petición. - Composables — funciones que encapsulan lógica reactiva reutilizable. Empiezan con
usey reemplazan a los mixins. Es el patrón estándar en Vue 3. computed— para filtrar datos en el cliente a partir de lo que ya tienes en memoria.watch— para detectar cambios en valores reactivos y disparar nuevas peticiones (paginación, búsqueda en servidor).- Debounce — esperar a que el usuario deje de escribir antes de hacer fetch, para no saturar la API con peticiones innecesarias.
Explorador Galáctico
Construye un Explorador Galáctico usando la Rick and Morty API. Tu aplicación debe incluir:
- Grid de personajes — muestra tarjetas con la imagen, nombre y estado (Alive, Dead, Unknown) de cada personaje. Usa colores diferentes para cada estado.
- Búsqueda en el servidor — un input que busque personajes por nombre usando el parámetro
?name=de la API. Implementa debounce de 300ms para no hacer peticiones innecesarias. - Paginación — botones "Anterior" y "Siguiente" que usen los campos
info.nexteinfo.prevde la respuesta de la API. Muestra la página actual y el total. - Composable
useCharacters— extrae toda la lógica de fetching a un composable ensrc/composables/useCharacters.js. Debe recibir los parámetros de búsqueda y página, y devolver{ characters, loading, error, totalPages, currentPage, fetchCharacters }. - Manejo de estados — muestra un indicador de carga, un mensaje de error con botón de reintentar, y un mensaje cuando la búsqueda no devuelve resultados.
lightbulb Pistas
Para el composable, recibe url base como parámetro y construye la URL final con los query params de búsqueda y página. Usa watch() dentro del composable para observar cambios en la página y volver a hacer fetch automáticamente. La respuesta de la API tiene la estructura { info: { count, pages, next, prev }, results: [...] } — usa info.pages para saber el total de páginas. Para el debounce en la búsqueda, puedes usar un setTimeout con clearTimeout dentro de un watch sobre el término de búsqueda.