TanStack Query para React: caché, recarga y mutaciones
Reemplace el código genérico de useEffect fetch por TanStack Query: claves de consulta, staleTime, gcTime, mutaciones y cuándo la biblioteca resulta innecesaria.
Cómo se almacena en caché, se actualiza y se refresca el estado asíncrono del servidor, y cuándo vale la pena agregar una biblioteca para ello.
Muchas aplicaciones React comienzan con la misma estructura para cargar datos: un useEffect, una llamada a fetch y unos pocos indicadores useState para mostrar elementos pendientes o errores. Ese patrón es adecuado como primer intento, pero también es donde suelen aparecer solicitudes duplicadas, pantallas obsoletas y código genérico copiado y pegado.
Las secciones siguientes analizan esas deficiencias y muestran cómo TanStack Query las soluciona. Los únicos requisitos previos son los hooks de React y conocimientos básicos de TypeScript. Los ejemplos utilizan DummyJSON, una API pública que no requiere clave API.
1. El patrón básico
Una lista de productos escrita con los hooks habituales se ve así.
import { useEffect, useState } from "react";
type Product = {
id: number;
title: string;
price: number;
};
function ProductList() {
const [products, setProducts] = useState<Product[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setIsLoading(true);
fetch("https://dummyjson.com/products?limit=10")
.then((res) => {
if (!res.ok) throw new Error("Request failed");
return res.json();
})
.then((data: { products: Product[] }) => {
if (!cancelled) setProducts(data.products);
})
.catch((err: Error) => {
if (!cancelled) setError(err.message);
})
.finally(() => {
if (!cancelled) setIsLoading(false);
});
return () => {
cancelled = true;
};
}, []);
if (isLoading) return <p>Loading…</p>;
if (error) return <p>{error}</p>;
return (
<ul>
{products.map((product) => (
<li key={product.id}>
{product.title} - ${product.price}
</li>
))}
</ul>
);
}
Esa lista ya es cuidadosa: utiliza una bandera cancelled para que una respuesta lenta no pueda actualizar el estado después de desmontarse. Muchos conjuntos de código reales omiten esa protección.
2. Qué no maneja este código
La operación de fetch en sí está bien. Los problemas están en los aspectos relacionados con ella.
- Sin caché. Al salir de la ruta y regresar, la llamada a la red se ejecuta nuevamente incluso cuando el contenido no ha cambiado.
- Sin deduplicación de solicitudes. Tres componentes que necesitan la misma lista de productos envían tres solicitudes idénticas.
- Sin intentos de reintentar. Una conexión interrumpida se convierte en un error en la interfaz, incluso cuando un segundo intento tendría éxito.
- Sin revalidación. Una pestaña dejada abierta durante una hora sigue mostrando datos antiguos hasta que algo más provoca una nueva carga.
cancelled ayuda en el proceso de desmontaje; no resuelve por completo los problemas de solicitudes concurrentes.Cada uno de estos problemas tiene una solución conocida. Implementar esas soluciones manualmente implica crear uno mismo una capa de caché.
3. La idea detrás de la biblioteca
Existe una distinción útil entre dos tipos de estado.
Estado del cliente está bajo el control de la interfaz de usuario: si un modal está abierto, el campo de formulario actual o el tema seleccionado. Solo cambia cuando tu código lo modifica. useState sirve para eso.
Estado del servidor es prestado. Se encuentra en un almacén que no controlas. Otros usuarios pueden modificarlo, y la copia en el navegador es solo una instantánea. Mantener esa instantánea únicamente en useState hace que parezca que una vista temporal es la autoritativa.
Los datos prestados necesitan un almacén dedicado: un lugar para la copia, una indicación de su antigüedad y una regla que determine cuándo volver a obtenerlos.
Un refrigerador es una analogía adecuada. La leche se queda en casa para no tener que ir a la tienda cada vez que se necesita café, pero caduca, por lo que se verifica la fecha y se reabastece antes de que se eche a perder. TanStack Query desempeña ese papel para las respuestas de API.
4. Qué es TanStack Query
TanStack Query gestiona el estado remoto asíncrono en aplicaciones de navegador. El proyecto está bajo licencia MIT, es gratuito para usar y se utiliza comúnmente en proyectos basados en React.
Las guías escritas hace años todavía mencionan React Query. Ese nombre se mantuvo hasta la versión 3. Con la versión 4, el proyecto pasó a llamarse TanStack Query tras lanzarse adaptadores para Vue, Svelte, Solid y Angular. En React aún se instala @tanstack/react-query; la versión 5 es la actual.
No sustituye a fetch ni a axios. La función de solicitud sigue siendo tuya. Alrededor de esa función, la biblioteca se encarga del control de tiempos, almacenamiento, actualidad y manejo de errores.
5. Configuración
Bastan dos pasos para comenzar.
npm install @tanstack/react-query
Luego se envuelve el árbol una sola vez en la raíz:
// main.tsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import App from "./App";
const queryClient = new QueryClient();
export default function Root() {
return (
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
);
}
QueryClient es la instancia de caché. QueryClientProvider la hace accesible para todos los componentes descendientes.
6. El mismo componente, reescrito
import { useQuery } from "@tanstack/react-query";
type Product = {
id: number;
title: string;
price: number;
};
async function fetchProducts(): Promise<Product[]> {
const res = await fetch("https://dummyjson.com/products?limit=10");
if (!res.ok) throw new Error("Request failed");
const data: { products: Product[] } = await res.json();
return data.products;
}
function ProductList() {
const { data, isPending, isError, error } = useQuery({
queryKey: ["products"],
queryFn: fetchProducts,
});
if (isPending) return <p>Loading…</p>;
if (isError) return <p>{error.message}</p>;
return (
<ul>
{data.map((product) => (
<li key={product.id}>
{product.title} - ${product.price}
</li>
))}
</ul>
);
}
Aproximadamente cuarenta líneas se reducen a unas quince. data se escribe como Product[] sin ninguna anotación adicional, ya que el tipo proviene directamente de fetchProducts. Después de los bucles isPending y isError, TypeScript considera que data ya está definido, por lo que no se aplica el encadenamiento opcional en map ni hay afirmación de que no sea nulo.
7. Qué te ofrece la versión más corta
En comparación con las carencias de la sección 2, los valores por defecto ya cubren los casos más comunes:
- Remount muestra de inmediato el contenido almacenado en caché y lo vuelve a validar en segundo plano.
- Las consultas en curso idénticas se combinan en una sola solicitud.
- Las solicitudes fallidas se intentan nuevamente automáticamente (tres intentos por defecto, con retroceso).
- Las entradas obsoletas se vuelven a cargar al enfocar la ventana, al reconectarse a la red o al volver a cargar el contenido.
Ninguno de estos comportamientos se configuró en el componente reescrito; son la opción predeterminada de la biblioteca.
8. Tres aspectos importantes de entender
La mayor parte de la confusión inicial se debe a las siguientes tres ideas.
La clave de consulta
queryKey es la dirección del caché. Dos componentes que utilizan ambos ["products"] comparten una misma entrada y una sola llamada a la red.
Regla práctica: toda valor del cual dependa la función de consulta debe aparecer en la clave.
function ProductList({ category }: { category: string }) {
const { data } = useQuery({
queryKey: ["products", category],
queryFn: () => fetchProductsByCategory(category),
});
// …
}
Si se omite category de la clave, cambiar de categoría aún puede mostrar la lista en caché de la categoría anterior. Este error es extremadamente común entre los usuarios principiantes.
staleTime y gcTime
Los nombres suenan similares pero significan cosas diferentes.
staleTime establece el período de vigencia. Dentro de ese período, la biblioteca omite las operaciones en red. Con el valor predeterminado de 0, un resultado se vuelve obsoleto de inmediato: la interfaz aún puede mostrar el valor almacenado en caché, pero cualquier evento desencadena una actualización en segundo plano. Aumente este valor cuando el contenido cambia raramente:
useQuery({
queryKey: ["products"],
queryFn: fetchProducts,
staleTime: 5 * 60 * 1000, // fresh for five minutes
});
gcTime indica cuánto tiempo permanecen los datos no utilizados en memoria después de que el último suscriptor se desconecte. El valor predeterminado es de cinco minutos. Al finalizar ese período, la entrada se elimina y la próxima visita comienza desde cero.
En resumen: staleTime regula la recarga; gcTime regula la eliminación.
Cuándo ocurre la recarga
Por defecto, una consulta obsoleta se vuelve a cargar cuando se monta un componente, cuando la ventana recupera el foco y cuando la red se vuelve a conectar. Cada uno de estos comportamientos puede desactivarse en el cliente o en una consulta específica:
useQuery({
queryKey: ["products"],
queryFn: fetchProducts,
refetchOnWindowFocus: false,
});
La opción de volver a cargar al recuperar el foco sorprende a las personas la primera vez que la ven. Mantenerla activa suele ser la razón por la cual una pestaña que permanece abierta durante mucho tiempo se mantiene actualizada.
9. Cambiar datos con useMutation
useQuery se utiliza para leer. useMutation se utiliza para escribir.
import { useMutation, useQueryClient } from "@tanstack/react-query";
type NewProduct = {
title: string;
price: number;
};
function AddProductButton() {
const queryClient = useQueryClient();
const { mutate, isPending } = useMutation({
mutationFn: async (product: NewProduct) => {
const res = await fetch("https://dummyjson.com/products/add", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(product),
});
if (!res.ok) throw new Error("Could not add product");
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["products"] });
},
});
return (
<button
onClick={() => mutate({ title: "New product", price: 25 })}
disabled={isPending}
>
{isPending ? "Saving…" : "Add product"}
</button>
);
}
La línea importante es invalidateQueries. Esa llamada marca como obsoletas todas las entradas del caché con el prefijo ["products"], de modo que los observadores montados solicitan datos nuevos de inmediato. Los arrays locales no se modifican manualmente, y la página no necesita recargarse completamente.
10. Las herramientas de desarrollo
npm install @tanstack/react-query-devtools
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
<QueryClientProvider client={queryClient}>
<App />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
Está en desarrollo; un panel muestra cada clave de consulta, estado, carga y hora de la última obtención. Observar cómo los registros pasan de ser recientes a obsoletos al hacer clic enseña el modelo de caché más rápido que leerlo solamente. El paquete se elimina automáticamente de las versiones para producción.
Errores comunes
- Sacar variables de la clave de consulta. Si la función de consulta lee un valor, esa clave debe incluirlo.
- Poner
useQuerydentro de unuseEffect. El hook ya se ejecuta durante el renderizado; no hay nada adicional que “activar”. - Usar la biblioteca para el estado puramente del cliente. Los campos de formulario y las flags de modal deben estar en
useState. - Establecer
staleTime: Infinityen todas partes. Eso desactiva la revalidación, lo que elimina la mayor parte de los beneficios.
data al estado local. De esta manera se mantienen dos copias, y la que se muestra deja de hacer seguimiento al caché.12. Cuándo puede no ser necesario
Si la aplicación accede a un único endpoint en una sola pantalla, el proveedor y los hooks pueden representar más formalidad de la que realmente requiere el problema.
Si el framework ya proporciona una capa de datos — componentes del servidor de Next.js o un router con cargadores — parte del trabajo ya está hecho. TanStack Query sigue siendo útil para las solicitudes interactivas desde el cliente, pero no es obligatorio.
Para estados que nunca salen del navegador, elija otra herramienta.
13. A dónde ir a continuación
El trabajo diario está cubierto por los conceptos anteriores. Los temas más avanzados se encuentran en otros lugares:
- Documentación oficial — material de referencia y demos interactivas
- Listas paginadas e infinitas — utiliza
useInfiniteQuerypara cargar más páginas al desplazarse - Consultas que esperan a otras — controla una solicitud posterior con
enabledhasta que se complete un requisito previo - UI optimista — muestra el resultado esperado antes de que llegue la respuesta de la mutación
Ten en cuenta este principio: los datos remotos deben almacenarse en un caché, no en el estado de componentes puntuales. Con este modelo como base, el resto de la API cobra sentido.