← Todos los artículos EN

Custom Hook para gestionar peticiones a la API en React

Deja de repetir el boilerplate de useEffect + fetch en cada componente. Construye un hook useAPI reutilizable que centraliza los estados de loading, error y datos.

Todo desarrollador de React ha escrito esto al menos una docena de veces:

const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);

useEffect(() => {
  setLoading(true);
  fetch('/api/users')
    .then(res => res.json())
    .then(setData)
    .catch(setError)
    .finally(() => setLoading(false));
}, []);

Funciona. Pero está duplicado en cada componente que toca una API. La solución es un custom hook que encapsula este patrón una sola vez y expone una interfaz limpia en todas partes.

El hook useAPI básico

import { useState, useEffect } from 'react';

interface UseAPIOptions {
  method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
  url: string;
  body?: unknown;
}

interface UseAPIResult<T> {
  data: T | null;
  loading: boolean;
  error: Error | null;
}

function useAPI<T>({ method = 'GET', url, body }: UseAPIOptions): UseAPIResult<T> {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    setLoading(true);
    setError(null);

    fetch(url, {
      method,
      body: body ? JSON.stringify(body) : undefined,
      headers: { 'Content-Type': 'application/json' },
    })
      .then(res => {
        if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
        return res.json() as Promise<T>;
      })
      .then(setData)
      .catch(setError)
      .finally(() => setLoading(false));
  }, [method, url, JSON.stringify(body)]);

  return { data, loading, error };
}

Usarlo en un componente

interface Usuario {
  id: number;
  nombre: string;
  email: string;
}

function ListaUsuarios() {
  const { data, loading, error } = useAPI<Usuario[]>({ url: '/api/usuarios' });

  if (loading) return <p>Cargando...</p>;
  if (error) return <p>Error: {error.message}</p>;
  if (!data) return null;

  return (
    <ul>
      {data.map(usuario => (
        <li key={usuario.id}>{usuario.nombre} — {usuario.email}</li>
      ))}
    </ul>
  );
}

Sin boilerplate en el componente. Los tres estados — loading, error, data — siempre disponibles sin repetición.

Soportando peticiones POST

El mismo hook maneja mutaciones. Para el envío de un formulario:

function CrearPost() {
  const [enviado, setEnviado] = useState(false);
  const [formData, setFormData] = useState({ titulo: '', cuerpo: '' });

  const { data, loading, error } = useAPI<{ id: number }>({
    method: 'POST',
    url: enviado ? '/api/posts' : '',
    body: enviado ? formData : undefined,
  });

  return (
    <form onSubmit={e => { e.preventDefault(); setEnviado(true); }}>
      <input
        value={formData.titulo}
        onChange={e => setFormData(f => ({ ...f, titulo: e.target.value }))}
        placeholder="Título"
      />
      <button type="submit" disabled={loading}>
        {loading ? 'Guardando...' : 'Guardar'}
      </button>
      {error && <p style={{ color: 'red' }}>{error.message}</p>}
      {data && <p>Post #{data.id} creado</p>}
    </form>
  );
}

Agregar soporte para abort

La versión básica tiene un bug: si el componente se desmonta mientras hay una petición en vuelo, la actualización de estado se ejecuta sobre un componente desmontado. Se corrige con AbortController:

useEffect(() => {
  const controller = new AbortController();

  setLoading(true);
  setError(null);

  fetch(url, {
    method,
    body: body ? JSON.stringify(body) : undefined,
    headers: { 'Content-Type': 'application/json' },
    signal: controller.signal,
  })
    .then(res => {
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      return res.json() as Promise<T>;
    })
    .then(setData)
    .catch(err => {
      if (err.name !== 'AbortError') setError(err);
    })
    .finally(() => setLoading(false));

  return () => controller.abort();
}, [method, url, JSON.stringify(body)]);

La función de cleanup cancela la petición cuando el componente se desmonta o las dependencias cambian — eliminando la condición de carrera.

Cuándo usar algo más completo

useAPI es ideal para casos simples. A medida que crecen los requisitos, considera:

  • TanStack Query — agrega caché, refetch en background, paginación y mutaciones con una API consistente
  • SWR — estrategia stale-while-revalidate, API mínima, ideal para apps de lectura intensiva
  • RTK Query — si ya usas Redux Toolkit, encaja naturalmente

Estas librerías resuelven los problemas que encontrarás después: deduplicación de peticiones concurrentes, invalidación de caché, actualizaciones optimistas y lógica de reintento. El custom hook es un buen escalón para entender por qué existen.

Conclusiones

  • Extrae el boilerplate de fetch + estado en un único hook useAPI y no lo vuelvas a escribir
  • Los genéricos de TypeScript (useAPI<Usuario[]>) propagan el tipo de retorno sin castings adicionales
  • Siempre agrega limpieza con AbortController para evitar actualizaciones de estado en componentes desmontados
  • Serializa el body en el array de dependencias de useEffect para evitar loops infinitos con referencias a objetos
  • Para apps en producción con necesidades complejas de data-fetching, TanStack Query vale la dependencia