En aplicaciones web a escala, TanStack Query (anteriormente React Query) v5 opera como una capa distribuida de caché asíncrona para el estado del servidor. La piedra angular de esta caché es la Query Key (clave de consulta): un array serializable de literales, identificadores y objetos de filtrado que actúa como clave primaria para la deduplicación, almacenamiento e invalidación de datos.
En arquitecturas poco rigurosas, las claves de consulta suelen declararse como arrays de cadenas dispersos a lo largo de los componentes:
// Código frágil: ¡un error tipográfico rompe la invalidación de caché en ejecución!
useQuery({ queryKey: ['users', id, 'detail'] });
queryClient.invalidateQueries({ queryKey: ['user', id, 'details'] }); // ¡Fallo silencioso!Para erradicar la desincronización y la deriva de tipos, las arquitecturas de producción adoptan el patrón Query Key Factory (fábrica de claves de consulta) en combinación con el asistente queryOptions() introducido en TanStack Query v5. Este enfoque proporciona una seguridad de tipos integral: desde la definición del endpoint hasta mutaciones optimistas respaldadas por un contexto de reversión fuertemente tipado (Rollback Context).
+-------------------------------------------------------------------------+
| ARQUITECTURA DE CACHÉ DE PUNTA A PUNTA EN V5 |
+-------------------------------------------------------------------------+
| [Query Key Factory: userKeys.detail(id)] |
| |--> Retorna una tupla estricta: readonly ['users', 'detail', string] |
+-------------------------------------------------------------------------+
|
v
| [queryOptions({ queryKey, queryFn })] |
| |--> Vincula el tipo de la clave con los argumentos de queryFn |
| |--> Infiere automáticamente TQueryFnData, TError, TData |
| `-- Reutilización isomórfica: prefetchQuery (SSR) <-> useQuery (Client)|
+-------------------------------------------------------------------------+
|
v
+-------------------------------------------------------------------------+
| [Mutación con tipado estricto: useMutation<User, Error, Vars, Context>] |
| |-- onMutate: (vars) => Promise<{ previousUser: User | undefined }> |
| |-- onError: (err, vars, context: Context | undefined) => Rollback |
| `-- onSettled: (data, err, vars, context) => Invalidate by Factory |
+-------------------------------------------------------------------------+El tipado en TanStack Query v5 se apoya en proyecciones jerárquicas y genéricos de cuatro parámetros:
['users']) como selecciones específicas (['users', 'list', { role: 'admin' }]).useQuery.Estructura jerárquica de Query Key Factory:
userKeys.all -------------------> ['users']
|
|-- userKeys.lists() -----> ['users', 'list']
| `-- userKeys.list(filter) -> ['users', 'list', { status: 'active' }]
|
`-- userKeys.details() ---> ['users', 'detail']
`-- userKeys.detail(id) ----> ['users', 'detail', '42']
¡Invalidar ['users'] descarta automáticamente todas las listas y vistas detalladas!MutationRollbackContext ante errores 500 del servidor.getNextPageParam y control estricto de tipos con InfiniteData<PageResponse>.queryOptions entre prefetchQuery en React Server Components y useSuspenseQuery en cliente.Implementación modular de una fábrica de claves para la entidad User sin librerías externas:
export interface UserListFilters {
readonly role?: 'admin' | 'member';
readonly search?: string;
readonly page?: number;
}
/**
* Fábrica jerárquica de claves de consulta para usuarios.
* Cada método retorna una tupla inmutable de solo lectura (readonly).
*/
export const userKeys = {
all: ['users'] as const,
lists: () => [...userKeys.all, 'list'] as const,
list: (filters: UserListFilters) => [...userKeys.lists(), filters] as const,
details: () => [...userKeys.all, 'detail'] as const,
detail: (userId: string) => [...userKeys.details(), userId] as const,
};
export type UserKeys = typeof userKeys;queryOptions y selectores de datos#En TanStack Query v5, la función queryOptions infiere de manera automática el retorno de queryFn:
import { queryOptions } from '@tanstack/react-query';
import { userKeys, UserListFilters } from './userKeys';
export interface UserDto {
readonly id: string;
readonly fullName: string;
readonly email: string;
readonly createdAt: string;
readonly balanceCents: number;
}
async function fetchUserById(userId: string, signal?: AbortSignal): Promise<UserDto> {
const res = await fetch(`/api/users/${userId}`, { signal });
if (!res.ok) {
throw new Error(`[ApiError] Error al obtener el usuario: ${res.statusText}`);
}
return (await res.json()) as UserDto;
}
/**
* Configuración segura de consulta de detalle de usuario.
* TQueryFnData = UserDto
* TError = Error
*/
export const userDetailQueryOptions = (userId: string) =>
queryOptions({
queryKey: userKeys.detail(userId),
queryFn: ({ signal }) => fetchUserById(userId, signal),
staleTime: 60 * 1000, // 1 minuto
});
/**
* Configuración con selector de datos:
* Transforma y extrae el nombre formateado y el saldo en dólares.
*/
export const userBalanceSummaryOptions = (userId: string) =>
queryOptions({
...userDetailQueryOptions(userId),
// TData se infiere del tipo retornado por select
select: (user): { name: string; balanceUsd: string } => ({
name: user.fullName,
balanceUsd: `$${(user.balanceCents / 100).toFixed(2)}`,
}),
});Al diseñar mutaciones optimistas, es fundamental vincular el tipo de retorno de onMutate con el parámetro context en onError:
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { userKeys } from './userKeys';
import { UserDto } from './userQueries';
export interface UpdateBalanceVariables {
readonly userId: string;
readonly deltaCents: number;
}
export interface MutationRollbackContext {
readonly previousUser: UserDto | undefined;
}
export function useUpdateUserBalance() {
const queryClient = useQueryClient();
return useMutation<UserDto, Error, UpdateBalanceVariables, MutationRollbackContext>({
mutationFn: async ({ userId, deltaCents }) => {
const res = await fetch(`/api/users/${userId}/balance`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ deltaCents }),
});
if (!res.ok) {
throw new Error(`[MutationError] Error al actualizar el saldo: ${res.status}`);
}
return (await res.json()) as UserDto;
},
// 1. Actualización optimista y captura de instantánea
onMutate: async ({ userId, deltaCents }) => {
const queryKey = userKeys.detail(userId);
// Cancelar refetches en curso para evitar sobrescribir el estado optimista
await queryClient.cancelQueries({ queryKey });
// Capturar estado actual de la caché
const previousUser = queryClient.getQueryData<UserDto>(queryKey);
// Escribir localmente el nuevo saldo de forma optimista
if (previousUser) {
queryClient.setQueryData<UserDto>(queryKey, {
...previousUser,
balanceCents: previousUser.balanceCents + deltaCents,
});
}
// Retornar el contexto de reversión fuertemente tipado
return { previousUser };
},
// 2. Reversión si ocurre un fallo en red o servidor
onError: (_err, { userId }, context) => {
if (context?.previousUser) {
queryClient.setQueryData(userKeys.detail(userId), context.previousUser);
}
},
// 3. Invalidación determinista tras finalizar la operación
onSettled: (_data, _error, { userId }) => {
void queryClient.invalidateQueries({ queryKey: userKeys.detail(userId) });
},
});
}import { useInfiniteQuery } from '@tanstack/react-query';
import { userKeys } from './userKeys';
import { UserDto } from './userQueries';
export interface PaginatedUsersResponse {
readonly users: ReadonlyArray<UserDto>;
readonly nextCursor: string | null;
}
export function useUserInfiniteList(role?: 'admin' | 'member') {
return useInfiniteQuery<
PaginatedUsersResponse, // TQueryFnData
Error, // TError
PaginatedUsersResponse, // TData
readonly ['users', 'list', { role?: 'admin' | 'member' }], // TQueryKey
string | null // TPageParam
>({
queryKey: userKeys.list({ role }),
initialPageParam: null,
queryFn: async ({ pageParam, signal }) => {
const url = new URL('/api/users', window.location.origin);
if (role) url.searchParams.set('role', role);
if (pageParam) url.searchParams.set('cursor', pageParam);
const res = await fetch(url.toString(), { signal });
if (!res.ok) throw new Error('Error al cargar la lista de usuarios');
return (await res.json()) as PaginatedUsersResponse;
},
getNextPageParam: (lastPage) => lastPage.nextCursor,
getPreviousPageParam: () => null,
});
}La fábrica crea una tupla inmutable, previniendo discrepancias de prefijos en la invalidación.
Combina clave, promesa de red y política de caché en un objeto isomórfico infiriendo tipos de datos.
onMutate cancela consultas activas, guarda la instantánea en MutationRollbackContext y aplica cambios locales.
Ante fallos, onError restaura la instantánea con el contexto. onSettled invalida la clave correspondiente.
import { QueryClient } from '@tanstack/react-query';
import { userKeys } from './userKeys';
export class EntityCacheManager {
constructor(private readonly queryClient: QueryClient) {}
/**
* Invalida todas las entradas de caché vinculadas a usuarios
*/
public async invalidateAllUsers(): Promise<void> {
await this.queryClient.invalidateQueries({
queryKey: userKeys.all,
});
}
/**
* Invalida únicamente las listas de usuarios manteniendo abiertas las vistas detalladas
*/
public async invalidateUserLists(): Promise<void> {
await this.queryClient.invalidateQueries({
queryKey: userKeys.lists(),
});
}
/**
* Purga de forma forzada el registro detallado de un usuario en memoria
*/
public removeUserDetail(userId: string): void {
this.queryClient.removeQueries({
queryKey: userKeys.detail(userId),
exact: true,
});
}
}Si se omite as const al declarar las claves, TypeScript amplía literales como 'users' o 'detail' al tipo general string[]. Esto desactiva el emparejamiento parcial (fuzzy matching) y el autocompletado de tuplas.
// ❌ INCORRECTO: Se amplía a string[]
export const badKeys = {
all: ['users'], // string[]
};
// ✅ CORRECTO: Tupla literal inmutable
export const goodKeys = {
all: ['users'] as const, // readonly ['users']
};Si se omite el cuarto parámetro genérico en useMutation<TData, TError, TVariables, TContext>, TypeScript infiere el parámetro context como unknown. Cualquier intento de acceder a context.previousUser en onError provocará un error de compilación.
// ❌ INCORRECTO: Contexto sin tipar (tipo unknown)
useMutation({
onMutate: () => ({ prev: 10 }),
onError: (err, vars, context) => {
// context.prev -> Error TS2339: Property 'prev' does not exist on type 'unknown'
}
});
// ✅ CORRECTO: Declaración explícita del genérico de contexto
useMutation<Data, Error, Vars, { prev: number }>({ ... });Si la función que realiza la petición no enlaza el signal con fetch o axios, el navegador no podrá abortar la conexión activa cuando el componente se desmonte o el usuario cambie de vista.
// ❌ INCORRECTO: Petición que no se puede abortar
queryFn: () => fetch('/api/data').then(r => r.json());
// ✅ CORRECTO: Vinculación de AbortSignal
queryFn: ({ signal }) => fetch('/api/data', { signal }).then(r => r.json());Modificar directamente propiedades de un objeto obtenido con getQueryData rompe la verificación de igualdad por referencia en React Fiber y bloquea las actualizaciones de los componentes suscritos.
// ❌ INCORRECTO: Mutación directa de la referencia en caché
const user = queryClient.getQueryData(['users', id]);
user.balance = 500; // PROHIBIDO: Viola la inmutabilidad!
// ✅ CORRECTO: Uso de función de actualización inmutable
queryClient.setQueryData(['users', id], (old: User | undefined) =>
old ? { ...old, balance: 500 } : undefined
);Declarar un objeto jerárquico con métodos que retornen tuplas readonly con as const.
Establecer configuraciones integrando queryKey, queryFn tipada, staleTime y selectores.
Utilizar queryOptions en prefetchQuery en el servidor para erradicar peticiones en cascada.
Tipar useMutation con TContext para garantizar reversiones seguras ante errores de red.
Invocar métodos de la fábrica de claves en onSettled para actualizar los datos de manera determinista.