Durante mucho tiempo gestioné el estado del servidor en React igual que el estado del cliente: con useState y useEffect. Funcionaba, pero el código era tedioso. Un useEffect para hacer el fetch, otro para actualizar cuando cambiaban los parámetros, lógica de loading, de error, de revalidación. Páginas enteras de código para algo que debería ser trivial.
TanStack Query (antes React Query) resuelve exactamente eso. Trata los datos del servidor como lo que son: una caché que puede quedar obsoleta y necesita actualizarse. Tú declaras qué datos quieres y cuándo, la librería se ocupa del fetching, caching, invalidación y sincronización. La versión 5, lanzada en 2023 y ahora madura en 2026, tiene una API más limpia y consistente que sus predecesoras.
Esta guía cubre todo lo que necesitas para usarla en proyectos reales: instalación, queries básicas, mutaciones, paginación, invalidación de caché y las novedades de v5. Los ejemplos son funcionales y extraídos de patrones que uso habitualmente.
Instalación y configuración inicial
TanStack Query v5 requiere React 18 o superior. La instalación es directa:
npm install @tanstack/react-query
# O con bun:
bun add @tanstack/react-query
# Opcional pero muy recomendado: devtools
npm install @tanstack/react-query-devtools
Envuelve tu aplicación con el QueryClientProvider en el punto de entrada:
// main.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutos por defecto
retry: 2, // reintentar 2 veces en error
refetchOnWindowFocus: true, // revalidar al volver a la pestaña
},
},
});
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
</React.StrictMode>
);
Las devtools son casi obligatorias durante el desarrollo. Muestran en tiempo real el estado de cada query: si están fetching, stale, fresh, o en error. Son del estilo de Redux DevTools pero mucho más simples de usar.
El hook useQuery: fetching declarativo
El bloque básico de TanStack Query es useQuery. En v5, la API usa un objeto de opciones en lugar de parámetros posicionales, lo que la hace más legible y extensible:
// hooks/useProductos.ts
import { useQuery } from '@tanstack/react-query';
interface Producto {
id: number;
nombre: string;
precio: number;
stock: number;
}
async function fetchProductos(): Promise<Producto[]> {
const res = await fetch('/api/productos');
if (!res.ok) throw new Error(`Error ${res.status}: ${res.statusText}`);
return res.json();
}
export function useProductos() {
return useQuery({
queryKey: ['productos'],
queryFn: fetchProductos,
staleTime: 1000 * 60 * 2, // datos frescos durante 2 minutos
});
}
Usar el hook en un componente:
// components/ListaProductos.tsx
import { useProductos } from '../hooks/useProductos';
export function ListaProductos() {
const { data, isLoading, isError, error } = useProductos();
if (isLoading) return <div>Cargando productos...</div>;
if (isError) return <div>Error: {error.message}</div>;
return (
<ul>
{data.map(producto => (
<li key={producto.id}>
{producto.nombre} - {producto.precio}€
{producto.stock === 0 && <span> (Agotado)</span>}
</li>
))}
</ul>
);
}
El queryKey es el identificador único de esta query en la caché. TanStack Query usa serialización profunda, así que ['productos', { categoria: 'libros' }] es una query diferente de ['productos']. Diseñar bien las queryKeys es una de las decisiones más importantes al estructurar el proyecto.
Queries con parámetros y queryKeys dinámicas
El patrón más común es hacer una query que depende de algún parámetro: el ID de un recurso, los filtros de búsqueda, la página actual. La clave está en incluir esos parámetros en el queryKey:
// hooks/useProducto.ts
export function useProducto(id: number) {
return useQuery({
queryKey: ['productos', id], // query separada para cada producto
queryFn: async () => {
const res = await fetch(`/api/productos/${id}`);
if (!res.ok) throw new Error('Producto no encontrado');
return res.json() as Promise<Producto>;
},
enabled: id > 0, // no ejecutar si el ID no es válido
staleTime: 1000 * 60 * 10, // cachear 10 minutos
});
}
// hooks/useProductosFiltrados.ts
interface Filtros {
categoria?: string;
precioMax?: number;
enStock?: boolean;
}
export function useProductosFiltrados(filtros: Filtros) {
return useQuery({
queryKey: ['productos', 'filtrados', filtros],
queryFn: async () => {
const params = new URLSearchParams();
if (filtros.categoria) params.set('categoria', filtros.categoria);
if (filtros.precioMax) params.set('precioMax', String(filtros.precioMax));
if (filtros.enStock !== undefined) params.set('enStock', String(filtros.enStock));
const res = await fetch(`/api/productos?${params}`);
return res.json() as Promise<Producto[]>;
},
});
}
Cuando los filtros cambian, TanStack Query automáticamente hace una nueva petición para los nuevos parámetros, mantiene el resultado anterior mientras carga (para evitar parpadeos) y cachea el resultado por separado. Todo ese comportamiento es gratis.
useMutation: modificar datos en el servidor
Para operaciones que modifican datos (POST, PUT, DELETE) se usa useMutation. El patrón habitual es mutar y luego invalidar las queries relacionadas para que se refresquen:
// hooks/useCrearProducto.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
interface CrearProductoDTO {
nombre: string;
precio: number;
stock: number;
}
export function useCrearProducto() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (datos: CrearProductoDTO): Promise<Producto> => {
const res = await fetch('/api/productos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(datos),
});
if (!res.ok) throw new Error('Error al crear el producto');
return res.json();
},
onSuccess: () => {
// Invalidar la lista de productos para que se recargue
queryClient.invalidateQueries({ queryKey: ['productos'] });
},
onError: (error) => {
console.error('Error creando producto:', error.message);
},
});
}
Usarlo en un formulario:
// components/FormularioProducto.tsx
export function FormularioProducto() {
const crearProducto = useCrearProducto();
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const form = new FormData(e.currentTarget);
crearProducto.mutate({
nombre: form.get('nombre') as string,
precio: Number(form.get('precio')),
stock: Number(form.get('stock')),
});
};
return (
<form onSubmit={handleSubmit}>
<input name="nombre" placeholder="Nombre" required />
<input name="precio" type="number" placeholder="Precio" required />
<input name="stock" type="number" placeholder="Stock" required />
<button type="submit" disabled={crearProducto.isPending}>
{crearProducto.isPending ? 'Guardando...' : 'Crear producto'}
</button>
{crearProducto.isError && (
<p style={{ color: 'red' }}>{crearProducto.error.message}</p>
)}
{crearProducto.isSuccess && (
<p style={{ color: 'green' }}>Producto creado correctamente</p>
)}
</form>
);
}
Fíjate en el cambio de v4 a v5: el estado de carga ahora se llama isPending en lugar de isLoading para las mutaciones. Un cambio pequeño pero que rompe bastante código al migrar si no prestas atención.
Actualizaciones optimistas para una UX fluida
Las actualizaciones optimistas son una técnica que consiste en actualizar la UI inmediatamente, antes de que el servidor confirme la operación, y revertir en caso de error. TanStack Query tiene soporte integrado para esto mediante onMutate, onError y onSettled:
export function useToggleTarea() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ id, completada }: { id: number; completada: boolean }) => {
const res = await fetch(`/api/tareas/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ completada }),
});
return res.json();
},
// Actualizar la caché optimistamente antes de la respuesta del servidor
onMutate: async ({ id, completada }) => {
await queryClient.cancelQueries({ queryKey: ['tareas'] });
// Guardar estado anterior para poder revertir
const estadoAnterior = queryClient.getQueryData<Tarea[]>(['tareas']);
// Actualizar inmediatamente
queryClient.setQueryData<Tarea[]>(['tareas'], (tareas) =>
tareas?.map(t => t.id === id ? { ...t, completada } : t)
);
return { estadoAnterior };
},
// Si falla, revertir
onError: (err, variables, context) => {
if (context?.estadoAnterior) {
queryClient.setQueryData(['tareas'], context.estadoAnterior);
}
},
// Siempre revalidar al finalizar (éxito o error)
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['tareas'] });
},
});
}
Paginación y scroll infinito
Para paginación básica puedes usar useQuery con la página como parámetro. TanStack Query mantiene el resultado anterior mientras carga la nueva página, evitando el parpadeo:
// Paginación básica
export function useProductosPaginados(pagina: number) {
return useQuery({
queryKey: ['productos', 'pagina', pagina],
queryFn: () => fetchProductosPagina(pagina),
placeholderData: keepPreviousData, // mantener datos anteriores mientras carga
});
}
// Scroll infinito con useInfiniteQuery
export function useProductosInfinitos() {
return useInfiniteQuery({
queryKey: ['productos', 'infinitos'],
queryFn: ({ pageParam }) => fetchProductosPagina(pageParam),
initialPageParam: 1,
getNextPageParam: (ultimaPagina, todasLasPaginas) => {
return ultimaPagina.hasMore
? todasLasPaginas.length + 1
: undefined; // undefined = no hay más páginas
},
});
}
Para el scroll infinito, keepPreviousData se llama ahora placeholderData: keepPreviousData en v5, otra de las diferencias con versiones anteriores. El useInfiniteQuery devuelve data.pages (array de páginas) y funciones fetchNextPage y hasNextPage para controlar la carga.
Precarga de datos con prefetchQuery
Una técnica que mejora mucho la UX es precargar datos antes de que el usuario los necesite. Por ejemplo, precargar la página siguiente mientras el usuario lee la actual, o precargar el detalle de un item cuando el cursor pasa por encima:
// Precargar al hacer hover sobre un elemento de la lista
function ItemProducto({ producto }: { producto: Producto }) {
const queryClient = useQueryClient();
const precargarDetalle = () => {
queryClient.prefetchQuery({
queryKey: ['productos', producto.id],
queryFn: () => fetchProducto(producto.id),
staleTime: 1000 * 60 * 5,
});
};
return (
<li onMouseEnter={precargarDetalle}>
<a href={`/productos/${producto.id}`}>{producto.nombre}</a>
</li>
);
}
// O precargar en el servidor (Next.js)
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
export default async function Page() {
const queryClient = new QueryClient();
await queryClient.prefetchQuery({
queryKey: ['productos'],
queryFn: fetchProductos,
});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<ListaProductos />
</HydrationBoundary>
);
}
Gestión de errores y retry logic
TanStack Query reintenta las queries fallidas automáticamente, con backoff exponencial. Puedes configurar cuántos reintentos hacer y bajo qué condiciones:
const queryClient = new QueryClient({
defaultOptions: {
queries: {
// No reintentar en errores 4xx (son errores del cliente, no transitorios)
retry: (failureCount, error) => {
if (error instanceof ApiError && error.status < 500) return false;
return failureCount < 3;
},
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
},
},
});
// Boundary de error para queries: usar React ErrorBoundary
import { QueryErrorResetBoundary } from '@tanstack/react-query';
import { ErrorBoundary } from 'react-error-boundary';
function AppConErrores() {
return (
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary
onReset={reset}
fallbackRender={({ resetErrorBoundary }) => (
<div>
<p>Algo salió mal.</p>
<button onClick={resetErrorBoundary}>Reintentar</button>
</div>
)}
>
<ListaProductos />
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
);
}
Organizar queryKeys con factories
Conforme crece la aplicación, gestionar los queryKeys como strings sueltos se vuelve difícil de mantener. Un patrón muy efectivo es crear una factory de queryKeys por entidad:
// queryKeys.ts - Factory pattern para queryKeys
export const productosKeys = {
all: ['productos'] as const,
lists: () => [...productosKeys.all, 'list'] as const,
list: (filtros: Filtros) => [...productosKeys.lists(), filtros] as const,
details: () => [...productosKeys.all, 'detail'] as const,
detail: (id: number) => [...productosKeys.details(), id] as const,
};
// Uso en los hooks
export function useProducto(id: number) {
return useQuery({
queryKey: productosKeys.detail(id), // ['productos', 'detail', id]
queryFn: () => fetchProducto(id),
});
}
// Invalidar todos los productos (lista y detalles)
queryClient.invalidateQueries({ queryKey: productosKeys.all });
// Invalidar solo las listas
queryClient.invalidateQueries({ queryKey: productosKeys.lists() });
Este patrón hace que la invalidación sea precisa y segura. Puedes invalidar solo lo necesario sin miedo a invalidar queries no relacionadas por un error de tipeo en el string.
Siguiente paso
Si tienes un proyecto React con useState y useEffect para fetching de datos, el mejor primer paso es migrar una sola pantalla a TanStack Query. Elige la más simple, la que tiene el fetch más básico sin paginación ni mutaciones. Verás de inmediato cómo desaparece el código de manejo de loading y error.
Desde ahí, el camino natural es añadir mutaciones a los formularios y configurar la invalidación de caché. La documentación oficial de TanStack Query es de las mejores del ecosistema React: clara, con ejemplos TypeScript y bien organizada. El apartado de «Important Defaults» al inicio es lectura obligatoria para evitar sorpresas con el comportamiento del refetching.