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
useAPIy no lo vuelvas a escribir - Los genéricos de TypeScript (
useAPI<Usuario[]>) propagan el tipo de retorno sin castings adicionales - Siempre agrega limpieza con
AbortControllerpara evitar actualizaciones de estado en componentes desmontados - Serializa el
bodyen el array de dependencias deuseEffectpara evitar loops infinitos con referencias a objetos - Para apps en producción con necesidades complejas de data-fetching, TanStack Query vale la dependencia