En la ingeniería de aplicaciones web modernas, un antipatrón recurrente ha sido intentar gobernar datos remotos del servidor con gestores de estado diseñados para el cliente (Redux, MobX, React Context).
El estado del servidor (Server State) difiere sustancialmente del estado del cliente (Client State). TanStack Query v5 resuelve la sincronización de datos remotos: no es un gestor de estado convencional, sino una caché distribuida asíncrona con revalidación automática, desduplicación de peticiones de red y recolección de basura del ciclo de vida de los datos.
Intentar alojar datos remotos dentro de almacenes tradicionales de cliente introduce tres fallos sistémicos en producción:
useEffect, sin desduplicación el navegador emitirá cinco peticiones HTTP idénticas en paralelo.isLoading e isError, temporizadores de expiración de caché inactiva, aborto de peticiones al desmontar vistas y reversiones optimistas requiere cientos de líneas de código repetitivo y propenso a errores.La interacción de los componentes con la caché del servidor descansa sobre cuatro pilares arquitectónicos:
useQuery con claves idénticas, se procesa una única llamada de red. Todos los componentes se suscriben a la misma promesa compartida en QueryCache.fresh), se resuelve instantáneamente desde memoria sin tocar la red. Al quedar obsoleta (stale), se sirve al instante mientras se lanza una revalidación silenciosa en segundo plano.['orders', 'detail', id] permite invalidaciones puntuales e invalidaciones masivas en cascada con seguridad de tipos.// Jerarquía de claves en TanStack Query
['orders'] // Todas las consultas de órdenes
['orders', 'list'] // Todas las listas de órdenes
['orders', 'list', { status }] // Lista filtrada
['orders', 'detail', orderId] // Detalle de orden específicoEl Server Component de Next.js crea un QueryClient aislado por petición HTTP entrante, impidiendo fugas de datos entre usuarios.
Ejecución de consultas en paralelo en el entorno Node.js/Edge, resolviendo promesas antes de comenzar a emitir el HTML.
Serialización del estado de la caché del servidor en un árbol JSON seguro transferido al cliente mediante HydrationBoundary.
Las invocaciones cliente de useQuery leen directamente los datos deshidratados sin mostrar esqueletos de carga intermedios.
Al vencer staleTime, TanStack Query dispara revalidaciones transparentes ante eventos como el foco de la ventana o reconexión de red.
¿Qué sucede si en un hook useQuery se define staleTime: 60000 (1 minuto) y gcTime: 10000 (10 segundos), tras lo cual el usuario navega a otra sección y regresa a los 20 segundos?
refetchOnWindowFocus) y sincronización entre pestañas.prefetchQuery) en Server Components y deshidratación dentro de <HydrationBoundary> para eliminar el Cumulative Layout Shift (CLS) y brindar un First Contentful Paint inmediato.AbortSignal.useInfiniteQuery (getNextPageParam, getPreviousPageParam) conservando la posición de desplazamiento al navegar hacia atrás.Dispersar arrays literales como ['users', userId, 'posts'] por los componentes provoca errores tipográficos e invalidaciones imprecisas. En producción se implementa el patrón Query Key Factory:
/**
* Fábrica jerárquica de claves de caché fuertemente tipada.
* Garantiza invalidaciones puntuales e invalidaciones en cascada por prefijo.
*/
export const orderKeys = {
all: ['orders'] as const,
lists: () => [...orderKeys.all, 'list'] as const,
list: (filters: { status?: string; page?: number }) =>
[...orderKeys.lists(), filters] as const,
details: () => [...orderKeys.all, 'detail'] as const,
detail: (id: string) => [...orderKeys.details(), id] as const,
metrics: (dateRange: { from: string; to: string }) =>
[...orderKeys.all, 'metrics', dateRange] as const,
};
// Ejemplos de invalidación:
// queryClient.invalidateQueries({ queryKey: orderKeys.all }) -> invalida absolutamente todo sobre órdenes
// queryClient.invalidateQueries({ queryKey: orderKeys.lists() }) -> invalida únicamente las listas
// queryClient.invalidateQueries({ queryKey: orderKeys.detail('42') }) -> invalida únicamente la orden #42En Next.js App Router, los datos se consultan en el servidor dentro de un Server Component mediante una instancia aislada de QueryClient, se serializan vía dehydrate y se inyectan en el cliente mediante HydrationBoundary:
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
import { orderKeys } from '@/api/queryKeys';
import { fetchOrderById } from '@/api/orders';
import { OrderDetailsView } from '@/components/OrderDetailsView';
interface PageProps {
params: Promise<{ id: string }>;
}
export default async function OrderPage({ params }: PageProps) {
const { id } = await params;
// Creamos un QueryClient aislado para esta petición HTTP entrante (Request-Scoped)
const queryClient = new QueryClient();
// Precarga de datos en la caché del servidor (runtime Node.js/Edge)
await queryClient.prefetchQuery({
queryKey: orderKeys.detail(id),
queryFn: ({ signal }) => fetchOrderById(id, signal),
});
return (
// El estado deshidratado se suministra al cliente sin cascadas de peticiones
<HydrationBoundary state={dehydrate(queryClient)}>
<OrderDetailsView orderId={id} />
</HydrationBoundary>
);
}'use client';
import { useQuery } from '@tanstack/react-query';
import { orderKeys } from '@/api/queryKeys';
import { fetchOrderById } from '@/api/orders';
export function OrderDetailsView({ orderId }: { orderId: string }) {
// Los datos se entregan INSTANTÁNEAMENTE desde la caché de HydrationBoundary sin parpadeo de carga
const { data: order, status, isFetching } = useQuery({
queryKey: orderKeys.detail(orderId),
queryFn: ({ signal }) => fetchOrderById(orderId, signal),
staleTime: 60 * 1000, // Los datos se consideran frescos durante 1 minuto
});
if (status === 'pending') {
return <div className="animate-pulse p-4">Esqueleto de carga...</div>;
}
if (status === 'error') {
return <div className="text-red-400 p-4">Error al cargar la orden</div>;
}
return (
<div className="p-6 border border-border rounded-xl bg-bg-surface">
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold font-sans">Orden #{order.id}</h1>
{isFetching && (
<span className="text-xs text-accent-cyan animate-pulse">
Sincronizando...
</span>
)}
</div>
<p className="mt-2 text-text-muted">Estado: {order.status}</p>
<p className="text-text-muted">Total: ${order.totalAmount}</p>
</div>
);
}El patrón Optimistic Update se ejecuta a través de tres fases discretas del hook useMutation:
onMutate: Abortar consultas activas, guardar una instantánea del estado de la caché y escribir el nuevo valor optimista mediante setQueryData.onError: Restaurar la instantánea previa si la red falla.onSettled: Forzar una invalidación para reconciliar con la verdad definitiva de la base de datos.Invocar queryClient.cancelQueries impide que una revalidación previa lenta sobrescriba la interfaz optimista.
Guardar el estado actual con getQueryData en el contexto devuelto. Aplicar de inmediato el nuevo valor con setQueryData.
Envío de la petición HTTP suministrando el AbortSignal. El usuario ya visualiza la interfaz actualizada con latencia cero.
Si la petición falla (500, Offline, 403), onError recupera previousData del contexto y reestablece el estado previo.
onSettled se ejecuta incondicionalmente (éxito o error), lanzando invalidateQueries para sincronizar con la base de datos.
¿Por qué es indispensable en onMutate invocar await queryClient.cancelQueries({ queryKey }) ANTES de ejecutar queryClient.setQueryData?
import { useMutation, useQueryClient } from '@tanstack/react-query';
export type TaskStatus = 'TODO' | 'IN_PROGRESS' | 'DONE';
export interface TaskItem {
readonly id: string;
readonly title: string;
readonly status: TaskStatus;
readonly updatedAt: string;
}
export interface UpdateTaskPayload {
readonly taskId: string;
readonly nextStatus: TaskStatus;
}
interface MutationContext {
previousTasks?: TaskItem[];
}
export const taskKeys = {
all: ['tasks'] as const,
lists: () => [...taskKeys.all, 'list'] as const,
};
async function mutateTaskStatusApi(
payload: UpdateTaskPayload,
signal?: AbortSignal
): Promise<TaskItem> {
const response = await fetch(`/api/tasks/${payload.taskId}/status`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: payload.nextStatus }),
signal,
});
if (!response.ok) {
throw new Error(`Error al actualizar el estado de la tarea: HTTP ${response.status}`);
}
return (await response.json()) as TaskItem;
}
export function useUpdateTaskStatus() {
const queryClient = useQueryClient();
return useMutation<TaskItem, Error, UpdateTaskPayload, MutationContext>({
mutationFn: (payload) => mutateTaskStatusApi(payload),
// 1. Fase de actualización optimista
onMutate: async (newTodoPayload) => {
// Cancelar refetches activos en la lista para evitar condiciones de carrera
await queryClient.cancelQueries({ queryKey: taskKeys.lists() });
// Guardar instantánea del estado de la caché para posibilitar la reversión
const previousTasks = queryClient.getQueryData<TaskItem[]>(taskKeys.lists());
// Escribir el nuevo estado optimista en caché
if (previousTasks) {
queryClient.setQueryData<TaskItem[]>(
taskKeys.lists(),
previousTasks.map((task) =>
task.id === newTodoPayload.taskId
? {
...task,
status: newTodoPayload.nextStatus,
updatedAt: new Date().toISOString(),
}
: task
)
);
}
// Devolver el contexto con la instantánea para onError
return { previousTasks };
},
// 2. Fase de reversión ante fallo
onError: (_err, _variables, context) => {
if (context?.previousTasks) {
queryClient.setQueryData(taskKeys.lists(), context.previousTasks);
}
},
// 3. Fase de reconciliación definitiva
onSettled: () => {
void queryClient.invalidateQueries({ queryKey: taskKeys.lists() });
},
});
}Declarar const queryClient = new QueryClient() dentro del cuerpo de un componente React destruye y crea una caché nueva en cada render. Todas las respuestas almacenadas previamente se descartan, provocando bucles interminables de peticiones de red.
// ❌ ANTIPATRÓN: ¡Se crea un QueryClient nuevo en cada renderizado!
export function BadAppProvider({ children }: { children: React.ReactNode }) {
const queryClient = new QueryClient(); // ¡LA CACHÉ SE ELIMINA EN CADA RENDER!
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}
// ✅ PATRÓN DE PRODUCCIÓN: Instancia única en cliente, una por petición en servidor
function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
},
},
});
}
let browserQueryClient: QueryClient | undefined = undefined;
function getQueryClient() {
if (typeof window === 'undefined') {
// Servidor: Siempre crear una nueva instancia por petición
return makeQueryClient();
}
// Navegador: Reutilizar instancia única persistente
if (!browserQueryClient) browserQueryClient = makeQueryClient();
return browserQueryClient;
}
export function GoodAppProvider({ children }: { children: React.ReactNode }) {
const queryClient = getQueryClient();
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}Si la función queryFn omite el parámetro signal, el navegador continuará descargando respuestas pesadas incluso después de que el usuario haya abandonado la vista o desmontado el componente.
// ❌ ANTIPATRÓN: La petición no puede abortarse al desmontar
useQuery({
queryKey: ['users', id],
queryFn: () => fetch(`/api/users/${id}`).then((res) => res.json()),
});
// ✅ PATRÓN DE PRODUCCIÓN: Pasar AbortSignal a la llamada fetch
useQuery({
queryKey: ['users', id],
queryFn: ({ signal }) =>
fetch(`/api/users/${id}`, { signal }).then((res) => {
if (!res.ok) throw new Error('Error de red');
return res.json();
}),
});Configurar gcTime por debajo de staleTime causa que el recolector de basura expulse las consultas inactivas de la memoria mientras su contenido todavía se considera fresco. Al volver a montar el componente, los usuarios verán indicadores de carga en lugar de una respuesta inmediata desde la caché.
// ❌ ANTIPATRÓN: gcTime purga la caché tras 10s, ¡a pesar de tener frescura de 5m!
useQuery({
queryKey: ['analytics'],
queryFn: fetchAnalytics,
staleTime: 5 * 60 * 1000, // 5 minutos
gcTime: 10 * 1000, // 10 segundos (¡Fallo de configuración crítico!)
});
// ✅ PATRÓN DE PRODUCCIÓN: gcTime SIEMPRE debe ser mayor o igual que staleTime
useQuery({
queryKey: ['analytics'],
queryFn: fetchAnalytics,
staleTime: 5 * 60 * 1000, // 5 minutos
gcTime: 15 * 60 * 1000, // 15 minutos
});Modificar directamente las propiedades del objeto data recibido produce inconsistencias silenciosas: React no detecta actualizaciones, las referencias de Fiber no cambian y la reversión de estados resulta inviable.
// ❌ ANTIPATRÓN: Mutación en memoria de la referencia de caché
const { data: user } = useQuery({ queryKey: ['user', id], queryFn: fetchUser });
const onClick = () => {
if (user) {
user.name = 'Nuevo Nombre'; // PROHIBIDO: ¡Mutación del objeto inmutable de caché!
}
};
// ✅ PATRÓN DE PRODUCCIÓN: Actualización inmutable mediante setQueryData
const queryClient = useQueryClient();
const onClickGood = () => {
queryClient.setQueryData(['user', id], (oldUser: User | undefined) => {
if (!oldUser) return undefined;
return { ...oldUser, name: 'Nuevo Nombre' };
});
};