17. Manejo de APIs y Fetch
Fetch y Axios en setup()
Section titled “Fetch y Axios en setup()”En Vue 3 con Composition API, tenemos dos opciones principales para realizar peticiones HTTP: la API nativa fetch y la librería axios. Ambas pueden utilizarse dentro de la función setup() para obtener datos de APIs externas.
Usando Fetch
Section titled “Usando Fetch”<script setup>import { ref, onMounted } from 'vue'
// Estados para manejar la peticiónconst data = ref(null)const loading = ref(false)const error = ref(null)
// Función para obtener datosconst fetchData = async () => {loading.value = trueerror.value = null
try { const response = await fetch('https://jsonplaceholder.typicode.com/posts/1')
if (!response.ok) { throw new Error('Error en la petición') }
data.value = await response.json()} catch (err) { error.value = err.message || 'Error desconocido'} finally { loading.value = false}}
// Ejecutar la petición al montar el componenteonMounted(() => {fetchData()})</script>
<template><div> <div v-if="loading">Cargando...</div> <div v-else-if="error">Error: {{ error }}</div> <div v-else-if="data"> <h2>{{ data.title }}</h2> <p>{{ data.body }}</p> </div></div></template><script setup>import { ref } from 'vue'
// Estados para el chatconst question = ref('')const chatHistory = ref([])const loading = ref(false)const error = ref(null)
// Función para obtener respuesta de la APIconst askQuestion = async () => {if (!question.value.trim()) return
// Guardar la pregunta en el historialconst currentQuestion = question.valuechatHistory.value.push({ type: 'question', content: currentQuestion, timestamp: new Date().toLocaleTimeString()})
// Limpiar el inputquestion.value = ''
// Mostrar estado de cargaloading.value = true
try { // Llamar a la API de YesNo const response = await fetch('https://yesno.wtf/api')
if (!response.ok) { throw new Error('Error al obtener respuesta') }
const data = await response.json()
// Añadir respuesta al historial chatHistory.value.push({ type: 'answer', content: data.answer.toUpperCase(), image: data.image, timestamp: new Date().toLocaleTimeString() })} catch (err) { error.value = err.message
// Añadir mensaje de error al historial chatHistory.value.push({ type: 'error', content: 'Error: ' + err.message, timestamp: new Date().toLocaleTimeString() })} finally { loading.value = false}}</script>
<template><div class="chat-container"> <h3>Chat de Sí o No</h3>
<!-- Historial de chat --> <div class="chat-history"> <div v-for="(message, index) in chatHistory" :key="index" :class="['message', message.type]">
<div class="message-header"> <span class="message-type">{{ message.type === 'question' ? 'Tú' : 'Bot' }}</span> <span class="message-time">{{ message.timestamp }}</span> </div>
<div class="message-content"> {{ message.content }} </div>
<!-- Mostrar imagen si existe --> <img v-if="message.image" :src="message.image" alt="Respuesta animada" class="response-gif" /> </div>
<!-- Indicador de carga --> <div v-if="loading" class="message loading"> <div class="typing-indicator"> <span></span> <span></span> <span></span> </div> </div> </div>
<!-- Formulario de entrada --> <div class="chat-input"> <input type="text" v-model="question" @keyup.enter="askQuestion" placeholder="Haz una pregunta de sí/no..." :disabled="loading" /> <button @click="askQuestion" :disabled="loading || !question.trim()"> Enviar </button> </div>
<!-- Mensaje de error global si existe --> <div v-if="error" class="error-message"> {{ error }} </div>
<style scoped> .chat-container { max-width: 500px; margin: 0 auto; border: 1px solid #ddd; border-radius: 8px; overflow: hidden; }
.chat-history { height: 300px; overflow-y: auto; padding: 15px; background: #f9f9f9; }
.message { margin-bottom: 15px; padding: 10px; border-radius: 8px; max-width: 80%; }
.question { background: #e3f2fd; margin-left: auto; text-align: right; }
.answer { background: #f1f1f1; }
.error { background: #ffebee; color: #c62828; }
.message-header { display: flex; justify-content: space-between; font-size: 0.8em; margin-bottom: 5px; }
.response-gif { width: 100%; border-radius: 4px; margin-top: 10px; }
.chat-input { display: flex; padding: 10px; background: white; border-top: 1px solid #ddd; }
input { flex: 1; padding: 8px; border: 1px solid #ddd; border-radius: 4px; margin-right: 8px; }
button { padding: 8px 16px; background: #2196f3; color: white; border: none; border-radius: 4px; cursor: pointer; }
button:disabled { background: #bdbdbd; cursor: not-allowed; }
.typing-indicator { display: flex; padding: 10px; }
.typing-indicator span { height: 8px; width: 8px; background: #bdbdbd; border-radius: 50%; margin: 0 2px; display: inline-block; animation: bounce 1.5s infinite ease-in-out; }
.typing-indicator span:nth-child(2) { animation-delay: 0.2s; }
.typing-indicator span:nth-child(3) { animation-delay: 0.4s; }
@keyframes bounce { 0%, 60%, 100% { transform: translateY(0); } 30% { transform: translateY(-5px); } } </style></div></template>Usando Axios
Section titled “Usando Axios”Para usar Axios, primero necesitas instalarlo:
npm install axios<script setup>import { ref, onMounted } from 'vue'import axios from 'axios'
// Estados para manejar la peticiónconst data = ref(null)const loading = ref(false)const error = ref(null)
// Función para obtener datosconst fetchData = async () => {loading.value = trueerror.value = null
try { const response = await axios.get('https://jsonplaceholder.typicode.com/posts/1') data.value = response.data} catch (err) { error.value = err.response?.data?.message || err.message || 'Error desconocido'} finally { loading.value = false}}
onMounted(() => {fetchData()})</script>
<template><div> <div v-if="loading">Cargando...</div> <div v-else-if="error">Error: {{ error }}</div> <div v-else-if="data"> <h2>{{ data.title }}</h2> <p>{{ data.body }}</p> </div></div></template>Estado loading, data, error
Section titled “Estado loading, data, error”Manejar correctamente los estados de una petición API es fundamental para una buena experiencia de usuario. En Composition API, usamos refs para controlar estos estados:
<script setup>import { ref } from 'vue'
// Estados para manejar el ciclo de vida de la peticiónconst data = ref(null) // Almacena los datos recibidosconst loading = ref(false) // Indica si la petición está en cursoconst error = ref(null) // Almacena errores si ocurren
// Función genérica para peticionesconst useApi = async (url, options = {}) => {loading.value = trueerror.value = null
try { const response = await fetch(url, options)
if (!response.ok) { throw new Error('Error HTTP: ' + response.status) }
data.value = await response.json() return data.value} catch (err) { error.value = err.message console.error('Error en la petición:', err) return null} finally { loading.value = false}}</script>En el template, puedes usar estos estados para mostrar diferentes contenidos según el estado de la petición:
<template><div class="api-container"> <!-- Estado de carga --> <div v-if="loading" class="loading-state"> <div class="spinner"></div> <p>Cargando datos...</p> </div>
<!-- Estado de error --> <div v-else-if="error" class="error-state"> <p>❌ Error: {{ error }}</p> <button @click="fetchData">Reintentar</button> </div>
<!-- Estado con datos --> <div v-else-if="data" class="data-state"> <h2>Datos recibidos:</h2> <pre>{{ JSON.stringify(data, null, 2) }}</pre> </div>
<!-- Estado inicial --> <div v-else class="empty-state"> <p>No hay datos disponibles</p> <button @click="fetchData">Cargar datos</button> </div></div></template>Peticiones reactivas con watch
Section titled “Peticiones reactivas con watch”Una de las ventajas de Composition API es la capacidad de crear peticiones reactivas que respondan a cambios en el estado. Podemos usar watch o watchEffect para esto:
Usando watch
Section titled “Usando watch”<script setup>import { ref, watch } from 'vue'import axios from 'axios'
// Estadosconst userId = ref(1)const userData = ref(null)const loading = ref(false)const error = ref(null)
// Función para obtener datos del usuarioconst fetchUserData = async (id) => {loading.value = trueerror.value = null
try { const response = await axios.get('https://jsonplaceholder.typicode.com/users/' + id) userData.value = response.data} catch (err) { error.value = err.message userData.value = null} finally { loading.value = false}}
// Observar cambios en userId y ejecutar la peticiónwatch(userId, (newId) => {fetchUserData(newId)}, { immediate: true }) // immediate:true ejecuta la función inmediatamente al montar</script>
<template><div> <div class="controls"> <label>Seleccionar usuario ID:</label> <input type="number" v-model.number="userId" min="1" max="10" /> </div>
<div v-if="loading">Cargando información del usuario...</div> <div v-else-if="error">Error: {{ error }}</div> <div v-else-if="userData" class="user-card"> <h2>{{ userData.name }}</h2> <p>Email: {{ userData.email }}</p> <p>Teléfono: {{ userData.phone }}</p> </div></div></template>Usando watchEffect
Section titled “Usando watchEffect”watchEffect es una alternativa más concisa cuando queremos ejecutar código reactivo que depende de múltiples valores:
<script setup>import { ref, watchEffect } from 'vue'import axios from 'axios'
// Estadosconst searchQuery = ref('')const category = ref('all')const results = ref([])const loading = ref(false)const error = ref(null)
// Función para buscar productosconst searchProducts = async () => {// No buscar si el query está vacíoif (!searchQuery.value.trim()) { results.value = [] return}
loading.value = trueerror.value = null
try { // Construir URL con parámetros de búsqueda const url = new URL('https://api.example.com/products') url.searchParams.append('q', searchQuery.value) if (category.value !== 'all') { url.searchParams.append('category', category.value) }
const response = await axios.get(url.toString()) results.value = response.data} catch (err) { error.value = err.message results.value = []} finally { loading.value = false}}
// Debounce para evitar demasiadas peticioneslet timeout = nullconst debouncedSearch = () => {clearTimeout(timeout)timeout = setTimeout(() => { searchProducts()}, 300)}
// Ejecutar búsqueda cuando cambie el query o la categoríawatchEffect(() => {// Acceder a las refs dentro de watchEffect las hace dependenciasconst query = searchQuery.valueconst cat = category.value
// Solo ejecutar si hay un queryif (query) { debouncedSearch()}})</script>
<template><div> <div class="search-controls"> <input type="text" v-model="searchQuery" placeholder="Buscar productos..." />
<select v-model="category"> <option value="all">Todas las categorías</option> <option value="electronics">Electrónica</option> <option value="clothing">Ropa</option> <option value="books">Libros</option> </select> </div>
<div v-if="loading">Buscando...</div> <div v-else-if="error">Error: {{ error }}</div> <div v-else-if="results.length > 0" class="results-grid"> <div v-for="item in results" :key="item.id" class="product-card"> <h3>{{ item.name }}</h3> <p>{{ item.price }} €</p> </div> </div> <div v-else-if="searchQuery.trim()" class="no-results"> No se encontraron resultados </div></div></template>Buenas Prácticas
Section titled “Buenas Prácticas”1. Centralizar la lógica de API en composables
Section titled “1. Centralizar la lógica de API en composables”Crea composables reutilizables para tus llamadas API:
import { ref } from 'vue'import axios from 'axios'
export function useApi() {const data = ref(null)const loading = ref(false)const error = ref(null)
const execute = async (url, options = {}) => { loading.value = true error.value = null
try { const response = await axios(url, options) data.value = response.data return response.data } catch (err) { error.value = err.response?.data?.message || err.message return null } finally { loading.value = false }}
return { data, loading, error, execute}}Uso en componentes:
<script setup>import { useApi } from '@/composables/useApi'
const { data, loading, error, execute } = useApi()
// Usar el composableconst fetchUsers = () => {execute('https://api.example.com/users')}</script>2. Implementar caché y persistencia
Section titled “2. Implementar caché y persistencia”import { ref } from 'vue'import axios from 'axios'
export function useCachedApi(cacheTime = 5 * 60 * 1000) { // 5 minutos por defectoconst cache = new Map()const data = ref(null)const loading = ref(false)const error = ref(null)
const execute = async (url, options = {}) => { const cacheKey = url + '-' + JSON.stringify(options) const cachedData = cache.get(cacheKey)
// Usar datos en caché si existen y no han expirado if (cachedData && Date.now() - cachedData.timestamp < cacheTime) { data.value = cachedData.data return cachedData.data }
loading.value = true error.value = null
try { const response = await axios(url, options) data.value = response.data
// Guardar en caché cache.set(cacheKey, { data: response.data, timestamp: Date.now() })
return response.data } catch (err) { error.value = err.response?.data?.message || err.message return null } finally { loading.value = false }}
return { data, loading, error, execute, clearCache: () => cache.clear()}}3. Manejar tokens de autenticación
Section titled “3. Manejar tokens de autenticación”import { ref } from 'vue'import axios from 'axios'
export function useAuthApi() {const instance = axios.create()const data = ref(null)const loading = ref(false)const error = ref(null)
// Interceptor para añadir tokeninstance.interceptors.request.use(config => { const token = localStorage.getItem('auth_token') if (token) { config.headers.Authorization = 'Bearer ' + token } return config})
// Interceptor para manejar errores 401 (token expirado)instance.interceptors.response.use( response => response, async error => { if (error.response?.status === 401) { // Intentar refrescar token o redirigir a login localStorage.removeItem('auth_token') window.location.href = '/login' } return Promise.reject(error) })
const execute = async (url, options = {}) => { loading.value = true error.value = null
try { const response = await instance(url, options) data.value = response.data return response.data } catch (err) { error.value = err.response?.data?.message || err.message return null } finally { loading.value = false }}
return { data, loading, error, execute}}4. Implementar cancelación de peticiones
Section titled “4. Implementar cancelación de peticiones”<script setup>import { ref, onUnmounted } from 'vue'import axios from 'axios'
const data = ref(null)const loading = ref(false)const error = ref(null)
// Crear token de cancelaciónconst controller = new AbortController()
const fetchData = async () => {loading.value = trueerror.value = null
try { const response = await axios.get('https://api.example.com/data', { signal: controller.signal // Pasar la señal para posible cancelación }) data.value = response.data} catch (err) { if (axios.isCancel(err)) { console.log('Petición cancelada:', err.message) } else { error.value = err.message }} finally { loading.value = false}}
// Cancelar peticiones pendientes al desmontar el componenteonUnmounted(() => {controller.abort('Componente desmontado')})</script>5. Manejar errores de forma consistente
Section titled “5. Manejar errores de forma consistente”import { ref, computed } from 'vue'
export function useApiError() {const apiError = ref(null)
const setError = (error) => { if (!error) { apiError.value = null return }
// Normalizar diferentes tipos de errores if (error.response) { // Error de respuesta del servidor const { status, data } = error.response apiError.value = { code: status, message: data.message || 'Error ' + status, details: data.errors || null } } else if (error.request) { // Error de red (sin respuesta) apiError.value = { code: 'NETWORK_ERROR', message: 'Error de conexión. Comprueba tu red.', details: null } } else { // Error de configuración apiError.value = { code: 'REQUEST_ERROR', message: error.message || 'Error desconocido', details: null } }}
const clearError = () => { apiError.value = null}
const errorMessage = computed(() => apiError.value?.message || null)const hasError = computed(() => apiError.value !== null)
return { apiError, setError, clearError, errorMessage, hasError}}