Un hook useFetch mínimo: cuándo se puede prescindir de React Query y sus limitaciones
Crea un gancho useFetch con caché pequeño que utilice AbortController, vencimiento por TTL y un complemento useMutation, y averigua con exactitud qué funcionalidades de React Query debes renunciar.
React Query (ahora TanStack Query) se ha convertido en la solución estándar para la carga de datos en React, y es un software excelente. Sin embargo, en muchos conjuntos de código casi cada llamada hace lo mismo: obtener datos, mostrar un spinner, mostrar un error y evitar volver a cargarlos. Esta guía crea un gancho useFetch pequeño y sin dependencias para ese caso, agrega caché con TTL y soporte para mutaciones, y es honesta sobre las limitaciones heredadas, para que puedas tomar decisiones según cada proyecto en lugar de seguir la configuración predeterminada.
Así es el uso típico
A menudo, el uso más común de React Query sigue un mismo patrón: una clave junto con una función que obtiene y parsea JSON.
const { data, isLoading, error } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json())
});
Solo se necesita carga, manejo de errores y caché. La biblioteca también incluye herramientas para la recarga en segundo plano, invalidación, paginación y desplazamiento infinito, actualizaciones optimistas, herramientas de desarrollo, sincronización del estado del servidor y mutaciones con capacidad de deshacer. Si no se utilizan ninguna de estas funciones, pagas por el tamaño del paquete y su complejidad solo por tres variables. Revisa tú mismo el tamaño actual del paquete; las cifras varían según la versión.
En qué es realmente bueno React Query
Su función principal es la gestión del estado del servidor, manteniendo la interfaz de usuario sincronizada con los datos en el servidor. Destaca en:
- La recarga en segundo plano mientras el usuario está en la página.
- La actualización de consultas relacionadas después de una mutación.
- Paginación y desplazamiento infinito.
- Actualizaciones optimistas antes de que el servidor confirme los cambios.
- Deduplicación: dos componentes que solicitan los mismos datos generan una sola solicitud.
Si los necesitas, merece la pena usarlo. Muchas pantallas solo necesitan realizar una solicitud al cargar, mostrar un estado de carga, manejar errores y no volver a solicitar datos innecesariamente.
Por qué los efectos escritos a mano son insuficientes
El enfoque clásico utiliza tres variables de estado y un efecto:
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
fetch(`/api/users/${userId}`)
.then(r => r.json())
.then(data => {
setUser(data);
setLoading(false);
})
.catch(err => {
setError(err.message);
setLoading(false);
});
}, [userId]);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return <div>{user.name}</div>;
}
Funciona, pero tiene desventajas:
- Respuestas obsoletas. Si el componente se desmonta o
userIdcambia durante la solicitud, la respuesta antigua sigue llamando asetState. React 18 eliminó la advertencia de actualización en componentes desmontados, pero una respuesta lenta para un ID anterior puede seguir sobrescribiendo datos más recientes. - Falta de deduplicación. Dos componentes, dos solicitudes.
- Falta de caché. Al volver a la pantalla, se realiza nuevamente la solicitud.
- Repetición en cada componente.
Creación del hook useFetch
El gancho mantiene un Map a nivel de módulo indexado por URL. El estado inicial se lee de él de forma síncrona, por lo que los datos en caché se renderizan sin un efecto de carga visible. En caso de éxito en el acceso a la caché, se obtiene la respuesta rápidamente; de lo contrario, se envía una señal AbortController con la solicitud, los estados no 2xx se consideran errores y los resultados se almacenan en caché. La función de limpieza cancela la solicitud cuando cambia la URL o el componente se desmonta, se ignora AbortError, y una URL inválida omite la consulta.
// useFetch.js
import { useState, useEffect, useRef } from 'react';
const cache = new Map();
export function useFetch(url, options = {}) {
const [state, setState] = useState({
data: cache.get(url) ?? null,
loading: !cache.has(url),
error: null
});
const abortRef = useRef(null);
useEffect(() => {
if (!url) return;
if (cache.has(url)) {
setState({ data: cache.get(url), loading: false, error: null });
return;
}
abortRef.current = new AbortController();
setState(prev => ({ ...prev, loading: true, error: null }));
fetch(url, { signal: abortRef.current.signal, ...options })
.then(r => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
})
.then(data => {
cache.set(url, data);
setState({ data, loading: false, error: null });
})
.catch(err => {
if (err.name === 'AbortError') return;
setState({ data: null, loading: false, error: err.message });
});
return () => abortRef.current?.abort();
}, [url]);
return state;
}
Aunque se indica como “25 líneas”, en realidad son más cercanas a cuarenta, pero sigue siendo un tamaño reducido. Proporciona estado de carga, manejo de errores, caché, cancelación de solicitudes obsoletas (véase condiciones de carrera que el retraso en las solicitudes no puede resolver), limpieza al desmontar y renderizaciones instantáneas desde la caché.
Dos matices importantes: options falta en las dependencias del efecto, por lo que cambiar únicamente los encabezados no volverá a cargar los datos, y la clave de caché basada únicamente en la URL permite que las solicitudes con cuerpos o encabezados de autenticación diferentes entren en conflicto.
Uso del gancho
El acceso a los datos se reduce a una sola línea. Su estructura es similar a la de React Query, pero la bandera utilizada es loading, no isLoading.
function UserProfile({ userId }) {
const { data: user, loading, error } = useFetch(`/api/users/${userId}`);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return <div>{user.name}</div>;
}
Dos componentes, una URL
Aquí el encabezado y la barra lateral leen los mismos datos del usuario.
function Header({ userId }) {
const { data: user } = useFetch(`/api/users/${userId}`);
return <div>Hello, {user?.name}</div>;
}
function Sidebar({ userId }) {
const { data: user } = useFetch(`/api/users/${userId}`);
return <div>{user?.email}</div>;
}
Es aquí donde el gancho hace promesas excesivas. Si ambos se montan al mismo tiempo, ninguno encuentra una entrada en la caché, por lo que se realizan dos solicitudes; solo los montajes posteriores se benefician. La verdadera deduplicación implica almacenar en caché la promesa en proceso, no solo los datos ya resueltos.
Recuperación condicional y parametrizada
Pasar null desactiva la solicitud, al igual que la opción enabled de React Query.
function UserPosts({ userId, enabled }) {
const { data, loading } = useFetch(enabled ? `/api/users/${userId}/posts` : null);
// Pass null to skip fetching
}
Dado que la URL es la dependencia del efecto, una URL generada a partir de las propiedades se vuelve a cargar cuando cambian category o page. Codifique la entrada del usuario con encodeURIComponent.
function ProductList({ category, page }) {
const url = `/api/products?category=${category}&page=${page}`;
const { data, loading, error } = useFetch(url);
// Refetches automatically when category or page changes
}
Un panel de control con tres fuentes
Tres llamadas se ejecutan en paralelo, cada sección con su propio esqueleto, sin proveedor ni configuración, y vuelve a renderizar desde la caché.
function Dashboard({ userId }) {
const { data: user, loading: userLoading } = useFetch(`/api/users/${userId}`);
const { data: stats, loading: statsLoading } = useFetch(`/api/stats/${userId}`);
const { data: activity, loading: actLoading } = useFetch(`/api/activity/${userId}`);
return (
<div>
<Header>
{userLoading ? <Skeleton /> : <UserInfo user={user} />}
</Header>
<StatsPanel>
{statsLoading ? <Skeleton /> : <Stats data={stats} />}
</StatsPanel>
<ActivityFeed>
{actLoading ? <Skeleton /> : <Activity items={activity} />}
</ActivityFeed>
</div>
);
}
Entradas que caducan con TTL
Tener una caché que nunca caduca es inadecuado para datos que cambian. Al almacenar una marca de tiempo por entrada y eliminar aquellas anteriores a cinco minutos se soluciona este problema:
const cache = new Map();
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
function setCache(url, data) {
cache.set(url, { data, timestamp: Date.now() });
}
function getCache(url) {
const entry = cache.get(url);
if (!entry) return null;
if (Date.now() - entry.timestamp > CACHE_TTL) {
cache.delete(url);
return null;
}
return entry.data;
}
Luego, el hook lee y escribe a través de estos auxiliares; solo se muestran las partes modificadas:
const cached = getCache(url);
if (cached) {
setState({ data: cached, loading: false, error: null });
return;
}
// ... fetch logic ...
.then(data => {
setCache(url, data);
setState({ data, loading: false, error: null });
})
Para los ciclos de vida por llamada, extraer ttl de las opciones y pasar el resto a fetch: valor corto para precios, largo para perfiles. El ejemplo declara data dos veces en un mismo ámbito solo para mostrar dos usos.
export function useFetch(url, options = {}) {
const { ttl = 5 * 60 * 1000, ...fetchOptions } = options;
// Use ttl in cache logic
}
// Usage
const { data } = useFetch('/api/live-prices', { ttl: 30 * 1000 }); // 30 second cache
const { data } = useFetch('/api/user-profile', { ttl: 60 * 60 * 1000 }); // 1 hour cache
Las entradas vencidas se eliminan solo al leerse, por lo que el Map no tiene límite en una aplicación de ejecución prolongada que accede a muchas URLs.
Un gancho complementario useMutation
Las escrituras son iniciadas por quien llama la función en lugar de al montarla. Este gancho hace seguimiento del estado de carga, errores y datos, envía un cuerpo en formato JSON, considera como error cualquier respuesta que no sea 2xx, y almacena así como vuelve a lanzar los errores.
// useMutation.js
import { useState } from 'react';
export function useMutation(url, method = 'POST') {
const [state, setState] = useState({
loading: false,
error: null,
data: null
});
const mutate = async (body) => {
setState({ loading: true, error: null, data: null });
try {
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
setState({ loading: false, error: null, data });
return data;
} catch (err) {
setState({ loading: false, error: err.message, data: null });
throw err;
}
};
return { ...state, mutate };
}
Dentro del formulario, el botón queda deshabilitado durante la solicitud y los errores se muestran a continuación. Tenga en cuenta que onSubmit recibe un evento, no los datos del formulario, por lo que el código real debe llamar a event.preventDefault() y construir el cuerpo, por ejemplo, a partir de new FormData(event.currentTarget).
function CreatePost() {
const { mutate, loading, error } = useMutation('/api/posts');
const handleSubmit = async (formData) => {
try {
const post = await mutate(formData);
console.log('Created:', post);
} catch (err) {
// error state handles this
}
};
return (
<form onSubmit={handleSubmit}>
{/* form fields */}
<button disabled={loading}>
{loading ? 'Creating...' : 'Create Post'}
</button>
{error && <p>{error}</p>}
</form>
);
}
Una mutación exitosa no afecta la caché de useFetch, por lo que las listas relacionadas permanecen obsoletas hasta que expire su TTL o usted elimine manualmente las claves.
Qué se sacrifica
Recarga en segundo plano
React Query realiza una recarga al enfocarse la ventana. Puede aproximarse eliminando la entrada de caché al enfocarla, pero este método por sí solo no vuelve a ejecutar el efecto; también se necesita un desencadenante como un contador de estado.
useEffect(() => {
const handleFocus = () => {
cache.delete(url); // Invalidate cache
// Trigger refetch
};
window.addEventListener('focus', handleFocus);
return () => window.removeEventListener('focus', handleFocus);
}, [url]);
Actualizaciones optimistas, herramientas de desarrollo, paginación e invalidación
- Actualizaciones optimistas con un rollback correcto son difíciles de implementar, y la biblioteca gana claramente en este aspecto.
- Herramientas de desarrollo que muestran el caché, las consultas y los tiempos de ejecución; la versión hecha en casa solo cuenta con
console.log. useInfiniteQueryestá bien diseñado; la paginación personalizada requiere notablemente más trabajo.- Invalidación global, como volver a cargar cada consulta de “posts”, se convierte en una limpieza manual del caché.
Elegir entre las dos
Preferir React Query para la invalidación basada en mutaciones, el recargue en segundo plano, el desplazamiento infinito o la paginación compleja, las herramientas de desarrollo, o cuando su equipo ya lo conoce. Un pequeño hook es adecuado cuando las pantallas se limitan a obtener y mostrar datos, el caché permanente o con tiempo de vida suficiente es suficiente, no importan las dependencias y la aplicación es pequeña a mediana. Si decide seguir utilizando esta biblioteca, consulte cómo estructurar una capa de datos de TanStack Query.
Migración gradual
Mueva un componente a la vez: cambie la importación y la llamada al hook, además de renombrar isLoading a loading.
// Before
import { useQuery } from '@tanstack/react-query';
const { data, isLoading, error } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json())
});
// After
import { useFetch } from './useFetch';
const { data, loading, error } = useFetch(`/api/users/${userId}`);
Cuando ya no haya nada que importe la biblioteca, desinstálela.
npm uninstall @tanstack/react-query
Puntos clave
- La mayoría de las operaciones de obtención de datos son simples, y un
Mapcon marcas de tiempo constituye un caché adecuado para necesidades sencillas.
AbortController es la solución nativa para las respuestas obsoletas o fuera de orden.Lecturas relacionadas
- Por qué existen las reglas de ganchos: Fiber, listas de ganchos y despachadores — Un recorrido por los componentes internos de React: nodos Fiber, doble buffering, carriles, la lista enlazada de ganchos y cómo cada familia de ganchos integrados almacena su estado y programa sus tareas.
- Mantener la página responsive: cuándo mover el trabajo de la CPU a un Web Worker — Aprenda por qué los bucles intensivos congelan la interfaz del navegador, cómo el patrón de mensajes de Web Worker descarga ese trabajo y cómo decidir si una tarea realmente merece un Worker.
- Fábricas soportadas por cierres: estado privado, desactivación temporal de eventos, límites de frecuencia y deshacer — Vea cómo los cierres mantienen el estado privado activo en las fábricas de JavaScript, por qué causan valores obsoletos en los temporizadores de React y cómo la desactivación temporal de eventos, los límites de frecuencia y el deshacer dependen de ellos.