Tema
Consumir APIs
La mayoría de las apps reales muestran datos que vienen de un servidor: productos, usuarios, pedidos. Aprenderás a pedir datos con fetch, manejar los estados de carga, error y éxito, cancelar peticiones con AbortController y enviar datos (POST, PUT, DELETE).
Concepto
Una petición a una API es asíncrona: tarda un tiempo y puede fallar. Tu interfaz debe contemplar siempre tres estados:
txt
┌──────────► éxito → muestra los datos
cargando ┤
└──────────► error → muestra un mensaje y opción de reintentarUsaremos APIs públicas de prueba:
https://jsonplaceholder.typicode.com(usuarios, posts, tareas).https://dummyjson.com(productos, carritos, usuarios, con búsqueda y paginación).
fetch en un efecto
jsx
import { useEffect, useState } from 'react'
export default function ListaUsuarios() {
const [usuarios, setUsuarios] = useState([])
const [cargando, setCargando] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
async function cargar() {
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}`)
const datos = await res.json()
setUsuarios(datos)
} catch (err) {
setError(err.message)
} finally {
setCargando(false)
}
}
cargar()
}, [])
if (cargando) return <p>Cargando usuarios...</p>
if (error) return <p role="alert">No se pudieron cargar: {error}</p>
return (
<ul>
{usuarios.map((u) => (
<li key={u.id}>
<strong>{u.name}</strong> — {u.email}
</li>
))}
</ul>
)
}Error común
useEffect(async () => { ... }) no funciona: un efecto no puede devolver una promesa (debe devolver nada o una función de limpieza). Define la función async dentro y llámala.
Error común
Olvidar revisar res.ok. fetch sólo lanza error si falla la red; un 404 o 500 se considera "exitoso" y terminarás intentando mostrar el JSON de error como datos.
Cancelar peticiones y condiciones de carrera
Si los datos dependen de una prop (por ejemplo, el id del usuario seleccionado) y cambia rápido, una respuesta vieja puede llegar después que la nueva y mostrar datos incorrectos. Además, si el componente se desmonta, no tiene sentido seguir esperando. Solución: AbortController.
jsx
import { useEffect, useState } from 'react'
function DetalleUsuario({ id }) {
const [usuario, setUsuario] = useState(null)
const [estado, setEstado] = useState('cargando') // 'cargando' | 'exito' | 'error'
useEffect(() => {
const controlador = new AbortController()
setEstado('cargando')
fetch(`https://jsonplaceholder.typicode.com/users/${id}`, { signal: controlador.signal })
.then((res) => {
if (!res.ok) throw new Error(`Error ${res.status}`)
return res.json()
})
.then((datos) => {
setUsuario(datos)
setEstado('exito')
})
.catch((err) => {
if (err.name === 'AbortError') return // cancelada a propósito: ignorar
setEstado('error')
})
// Limpieza: cancela la petición si cambia el id o se desmonta
return () => controlador.abort()
}, [id])
if (estado === 'cargando') return <p>Cargando…</p>
if (estado === 'error') return <p>Error al cargar el usuario</p>
return (
<div>
<h3>{usuario.name}</h3>
<p>{usuario.company.name} · {usuario.address.city}</p>
</div>
)
}
export default function App() {
const [id, setId] = useState(1)
return (
<>
{[1, 2, 3, 4, 5].map((n) => (
<button key={n} onClick={() => setId(n)} disabled={n === id}>Usuario {n}</button>
))}
<DetalleUsuario id={id} />
</>
)
}Un cliente de API reutilizable
En vez de repetir fetch, res.ok y headers en cada componente, centraliza:
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 API usa cookies (ver curso de Node)
})
// 204 No Content no tiene cuerpo
const datos = res.status === 204 ? null : await res.json().catch(() => null)
if (!res.ok) {
throw new ApiError(res.status, datos?.error ?? datos?.message ?? `Error ${res.status}`, datos)
}
return datos
}
// Atajos
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.patch = (ruta, body, op) => api(ruta, { ...op, method: 'PATCH', body })
api.delete = (ruta, op) => api(ruta, { ...op, method: 'DELETE' })Variable de entorno en .env de Vite (sólo las que empiezan con VITE_ llegan al navegador):
env
VITE_API_URL=https://dummyjson.comCaso real: catálogo con búsqueda, paginación y CRUD
jsx
import { useEffect, useState } from 'react'
import { api } from './lib/api'
const POR_PAGINA = 8
export default function Catalogo() {
const [productos, setProductos] = useState([])
const [total, setTotal] = useState(0)
const [pagina, setPagina] = useState(1)
const [busqueda, setBusqueda] = useState('')
const [termino, setTermino] = useState('')
const [cargando, setCargando] = useState(true)
const [error, setError] = useState(null)
const [recarga, setRecarga] = useState(0) // para el botón "Reintentar"
// Debounce de la búsqueda
useEffect(() => {
const id = setTimeout(() => {
setTermino(busqueda.trim())
setPagina(1)
}, 400)
return () => clearTimeout(id)
}, [busqueda])
// Cargar productos cuando cambian página, término o se pide recargar
useEffect(() => {
const controlador = new AbortController()
const skip = (pagina - 1) * POR_PAGINA
const ruta = termino
? `/products/search?q=${encodeURIComponent(termino)}&limit=${POR_PAGINA}&skip=${skip}`
: `/products?limit=${POR_PAGINA}&skip=${skip}&select=title,price,thumbnail,category`
setCargando(true)
setError(null)
api
.get(ruta, { signal: controlador.signal })
.then((datos) => {
setProductos(datos.products)
setTotal(datos.total)
})
.catch((err) => err.name !== 'AbortError' && setError(err.message))
.finally(() => !controlador.signal.aborted && setCargando(false))
return () => controlador.abort()
}, [pagina, termino, recarga])
async function eliminar(id) {
if (!confirm('¿Eliminar este producto?')) return
const anterior = productos
// Actualización optimista: lo quitamos antes de que responda el servidor
setProductos((ps) => ps.filter((p) => p.id !== id))
try {
await api.delete(`/products/${id}`) // dummyjson simula la eliminación
} catch {
setProductos(anterior) // si falla, revertimos
alert('No se pudo eliminar')
}
}
async function crear() {
const nuevo = await api.post('/products/add', { title: 'Producto nuevo', price: 99 })
setProductos((ps) => [{ ...nuevo, thumbnail: 'https://placehold.co/100' }, ...ps])
}
const paginas = Math.max(1, Math.ceil(total / POR_PAGINA))
return (
<main style={{ maxWidth: 900, margin: '2rem auto', fontFamily: 'system-ui', padding: '0 1rem' }}>
<header style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
<input
style={{ flex: 1, padding: 8 }}
placeholder="Buscar productos…"
value={busqueda}
onChange={(e) => setBusqueda(e.target.value)}
/>
<button onClick={crear}>+ Nuevo</button>
</header>
{error && (
<div role="alert" style={{ background: '#fee2e2', padding: 12, borderRadius: 8 }}>
{error} <button onClick={() => setRecarga((r) => r + 1)}>Reintentar</button>
</div>
)}
{cargando ? (
<p>Cargando…</p>
) : productos.length === 0 ? (
<p>No se encontraron productos para "{termino}".</p>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))', gap: 16 }}>
{productos.map((p) => (
<article key={p.id} style={{ border: '1px solid #eee', borderRadius: 12, padding: 12 }}>
<img src={p.thumbnail} alt={p.title} width="100%" height={120} style={{ objectFit: 'cover' }} />
<h3 style={{ fontSize: 16 }}>{p.title}</h3>
<p>${p.price}</p>
<button onClick={() => eliminar(p.id)}>Eliminar</button>
</article>
))}
</div>
)}
<nav style={{ display: 'flex', gap: 8, justifyContent: 'center', marginTop: 24 }}>
<button onClick={() => setPagina((p) => p - 1)} disabled={pagina === 1 || cargando}>← Anterior</button>
<span>Página {pagina} de {paginas}</span>
<button onClick={() => setPagina((p) => p + 1)} disabled={pagina >= paginas || cargando}>Siguiente →</button>
</nav>
</main>
)
}Buena práctica
Hacer fetch en efectos funciona, pero en apps reales repetirás lo mismo: caché, reintentos, recarga al volver a la pestaña, sincronizar tras mutaciones... Para eso existe TanStack Query, que verás en la lección 15. Aprende primero esta base para entender lo que esa librería resuelve.
Seguridad
Nunca pongas claves secretas de APIs (Stripe secret, OpenAI, contraseñas de bases de datos) en variables VITE_: quedan visibles en el JavaScript público. Esas llamadas deben pasar por tu backend (curso de Node).
Ejercicios
- Muestra los posts de
https://jsonplaceholder.typicode.com/posts?userId=1con un selector de usuario (1 a 10) que vuelva a cargar los posts al cambiar, cancelando la petición anterior. - Agrega un formulario que cree un post con
POST https://jsonplaceholder.typicode.com/postsy lo agregue al inicio de la lista. Deshabilita el botón mientras se envía.
Ver solución
jsx
import { useEffect, useState } from 'react'
const URL = 'https://jsonplaceholder.typicode.com/posts'
export default function Posts() {
const [userId, setUserId] = useState(1)
const [posts, setPosts] = useState([])
const [cargando, setCargando] = useState(true)
const [titulo, setTitulo] = useState('')
const [enviando, setEnviando] = useState(false)
// 1. Cargar al cambiar de usuario
useEffect(() => {
const c = new AbortController()
setCargando(true)
fetch(`${URL}?userId=${userId}`, { signal: c.signal })
.then((r) => r.json())
.then((datos) => { setPosts(datos); setCargando(false) })
.catch((e) => e.name !== 'AbortError' && setCargando(false))
return () => c.abort()
}, [userId])
// 2. Crear
async function crear(e) {
e.preventDefault()
if (!titulo.trim()) return
setEnviando(true)
try {
const res = await fetch(URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: titulo, body: '...', userId })
})
if (!res.ok) throw new Error()
const nuevo = await res.json()
setPosts((ps) => [{ ...nuevo, id: Date.now() }, ...ps]) // la API siempre devuelve id 101
setTitulo('')
} catch {
alert('No se pudo crear')
} finally {
setEnviando(false)
}
}
return (
<div>
<select value={userId} onChange={(e) => setUserId(Number(e.target.value))}>
{Array.from({ length: 10 }, (_, i) => i + 1).map((n) => (
<option key={n} value={n}>Usuario {n}</option>
))}
</select>
<form onSubmit={crear}>
<input value={titulo} onChange={(e) => setTitulo(e.target.value)} placeholder="Título del post" />
<button disabled={enviando}>{enviando ? 'Creando…' : 'Crear'}</button>
</form>
{cargando ? <p>Cargando…</p> : (
<ul>{posts.map((p) => <li key={p.id}>{p.title}</li>)}</ul>
)}
</div>
)
}Resumen
- Maneja siempre tres estados: cargando, error y éxito.
fetchno lanza error con 4xx/5xx: revisares.ok.- Usa
AbortControlleren la limpieza del efecto para cancelar peticiones obsoletas. - Centraliza las llamadas en un cliente (
api.js) y usaVITE_API_URLpara la URL base. - Para apps reales, TanStack Query simplifica caché, reintentos y sincronización.