Skip to content

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 reintentar

Usaremos 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.com

Caso 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 ​

  1. Muestra los posts de https://jsonplaceholder.typicode.com/posts?userId=1 con un selector de usuario (1 a 10) que vuelva a cargar los posts al cambiar, cancelando la petición anterior.
  2. Agrega un formulario que cree un post con POST https://jsonplaceholder.typicode.com/posts y 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.
  • fetch no lanza error con 4xx/5xx: revisa res.ok.
  • Usa AbortController en la limpieza del efecto para cancelar peticiones obsoletas.
  • Centraliza las llamadas en un cliente (api.js) y usa VITE_API_URL para la URL base.
  • Para apps reales, TanStack Query simplifica caché, reintentos y sincronización.

Academia Web — aprende, copia y construye.