Estructurando una capa de datos de TanStack Query, desde queryOptions hasta los retrocesos
Construya una capa de datos TanStack Query paso a paso: opciones de consulta compartidas, generadores de claves, selectores, paginación, precarga, invalidación centralizada y actualizaciones óptimas seguras.
Los proyectos que utilizan TanStack Query (anteriormente React Query) tienden a acumular claves duplicadas, invalidaciones olvidadas y elementos de carga por todas partes, no debido a la biblioteca en sí sino a la falta de estructura adecuada. Esta guía desarrolla una función específica, una pantalla de contactos, a partir de una sola consulta, hasta crear una pequeña capa de datos con opciones compartidas, generadores de claves, invalidación automática y eliminaciones óptimas, señalando además las trampas que cada enfoque oculta. Si aún está decidiendo dónde debería residir el estado del servidor, nuestra comparación entre React Query y Redux para el estado del servidor aborda primero esa cuestión.
La consulta más útil posible
Una consulta necesita una clave y una función. Este componente carga los contactos y gestiona los estados de espera y de error.
import { useQuery } from '@tanstack/react-query';
import { getContacts } from './api/client';
function ContactsTable() {
const { data, isPending, isError, refetch } = useQuery({
queryKey: ['contacts'],
queryFn: getContacts,
});
if (isPending) return <LoadingSpinner />;
if (isError) return <ErrorAlert onRetry={refetch} />;
return <Table data={data} />;
}
queryKey no es una etiqueta. Es la identidad de la entrada del caché: cada componente que solicita ['contacts'] comparte los mismos datos, la misma deduplicación de solicitudes y el mismo proceso de recarga en segundo plano. Casi todos los patrones que se describen a continuación tienen como objetivo principal gestionar adecuadamente las claves.
Compartir definiciones de consultas
Rodear consultas repetidas con un gancho personalizado
Cuando dos componentes necesitan los mismos datos, un gancho personalizado almacena una sola definición y deja que los componentes se encarguen únicamente de la presentación.
// queries/contacts.ts
export function useContacts() {
return useQuery({
queryKey: ['contacts'],
queryFn: getContacts,
});
}
// Component
function ContactsTable() {
const { data, isPending, isError } = useContacts();
// Clean, focused component logic
}
Preferir objetos queryOptions sobre ganchos
Un gancho solo puede ser llamado dentro de un componente. Un objeto de opciones sencillo creado con queryOptions puede ser utilizado por ganchos, por prefetchQuery, por getQueryData y por los cargadores de rutas.
import { queryOptions } from '@tanstack/react-query';
export const contactsQueryOptions = queryOptions({
queryKey: ['contacts'],
queryFn: getContacts,
});
Tiene dos ventajas. Primero, la inferencia de tipos: las etiquetas queryOptions asignan al clave el tipo de datos de la consulta, por lo que queryClient.getQueryData(contactsQueryOptions.queryKey) devuelve un valor ya tipado sin necesidad de usar generics manuales. Segundo, la composibilidad: se puede extender el objeto y sobrescribir o añadir campos según el lugar de llamada, tal como lo hace el segundo componente con select.
// Use directly
function ContactsList() {
const { data } = useQuery(contactsQueryOptions);
return <List items={data} />;
}
// Extend with custom selectors
function ContactsCount() {
const { data } = useQuery({
...contactsQueryOptions,
select: (contacts) => contacts.length,
});
return <Badge count={data} />;
}
Leer datos de manera eficiente
Seleccionadores que limitan las actualizaciones de renderizado
select transforma los datos en caché antes de que lleguen al componente, y también sirve como herramienta para optimizar el renderizado. También puede formar parte de las opciones compartidas:
const contactsQueryOptions = queryOptions({
queryKey: ['contacts'],
queryFn: getContacts,
select: (data) => data.length,
});
El componente se vuelve a renderizar en función del resultado seleccionado. Si solo se selecciona el conteo y el servidor cambia el nombre de un contacto, el conteo permanece igual y el componente no se actualiza. En pantallas donde muchos componentes leen la misma lista extensa, esto evita muchas renderizaciones innecesarias. Una advertencia: una flecha de select inline genera una nueva función en cada renderización, por lo que se ejecuta nuevamente cada vez; para transformaciones costosas, defínala fuera del componente o memorízala.
Consultas parametrizadas: cada entrada debe estar en la clave
Una página de detalles necesita una consulta que dependa de un ID. Una función de fábrica que devuelve las opciones mantiene todo organizado.
export const contactQueryOptions = (contactId: string) =>
queryOptions({
queryKey: ['contacts', contactId],
queryFn: () => getContact(contactId),
});
// Usage
function ContactPage() {
const { id } = useParams();
const { data } = useQuery(contactQueryOptions(id));
return <ContactDetails contact={data} />;
}
La regla que evita uno de los errores más comunes en producción: toda variable que utilice la función de consulta debe aparecer en la clave. Si se omite contactId, el caché trata a todos los contactos como una sola entrada, por lo que un usuario podría ver brevemente los detalles de otra persona. Con un enrutador, también hay que tener en cuenta que id puede ser undefined; la opción enabled permite mantener la consulta hasta que ese valor esté disponible.
La paginación es una consulta parametrizada más el estado
La paginación no requiere nada especial: el número de página se incluye en la clave, y cambiarlo en el estado genera una nueva consulta.
export const paginatedContactsOptions = (page: number, pageSize: number) =>
queryOptions({
queryKey: ['contacts', 'paginated', page, pageSize],
queryFn: () => getContacts({ page, pageSize }),
});
function ContactsTable() {
const [page, setPage] = useState(1);
const { data } = useQuery(paginatedContactsOptions(page, 10));
return (
<>
<Table data={data.items} />
<Pagination
currentPage={page}
onNext={() => setPage(p => p + 1)}
/>
</>
);
}
No es necesario ningún efecto especial para volver a cargar los datos; una nueva clave simplemente significa una nueva consulta. En la práctica son importantes dos mejoras. En la primera renderización data es undefined, por lo que data.items necesita una protección. Además, cada vez que cambia de página la nueva clave comienza vacía, lo que hace que la tabla parpadee; al establecer placeholderData: keepPreviousData se mantiene visible la página anterior mientras se carga la siguiente.
Precargar la página que los usuarios solicitarán a continuación
Combinando esto con la precarga, la página siguiente suele estar lista antes incluso de hacer clic.
function ContactsTable() {
const [page, setPage] = useState(1);
const queryClient = useQueryClient();
const { data } = useQuery(paginatedContactsOptions(page, 10));
useEffect(() => {
// Silently load the next page in the background
queryClient.prefetchQuery(
paginatedContactsOptions(page + 1, 10)
);
}, [page, queryClient]);
return <Table data={data} />;
}
prefetchQuery llena la caché sin suscribir un componente. Cuando el usuario pasa a la página siguiente, la consulta encuentra datos nuevos y los muestra de inmediato. También funciona al pasar el cursor o antes de que se cargue una ruta, pero hay que ser preciso: cada precarga representa una solicitud real.
Listas infinitas con cursores
Para “cargar más” o desplazamiento infinito, useInfiniteQuery almacena una lista de páginas y gestiona el cursor por usted. Usted describe cómo encontrar el siguiente cursor en getNextPageParam, y la biblioteca se encarga del resto.
export const infiniteContactsOptions = queryOptions({
queryKey: ['contacts', 'infinite'],
queryFn: ({ pageParam }) => getContacts({ cursor: pageParam }),
initialPageParam: undefined,
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
function InfiniteContactsList() {
const {
data,
fetchNextPage,
isFetchingNextPage
} = useInfiniteQuery(infiniteContactsOptions);
return (
<>
{data.pages.map(page =>
page.items.map(contact => (
<ContactCard key={contact.id} {...contact} />
))
)}
<button onClick={() => fetchNextPage()}>
{isFetchingNextPage ? 'Loading...' : 'Load More'}
</button>
</>
);
}
Tenga en cuenta que en TanStack Query v5 la función auxiliar dedicada para este caso es infiniteQueryOptions, la cual asigna los tipos correctos a los campos infinitos; consulte la documentación actual si queryOptions los rechaza. También utilice hasNextPage para ocultar el botón una vez que getNextPageParam devuelva undefined.
Mantener las claves consistentes con una fábrica
Las claves escritas a mano varían: un archivo escribe ['contacts', 'list'], otro ['contact', 'lists'], y la invalidación pasa desapercibida. Una fábrica de claves describe la jerarquía una sola vez.
export const contactKeys = {
all: ['contacts'] as const,
lists: () => [...contactKeys.all, 'list'] as const,
list: (filters: ContactFilters) =>
[...contactKeys.lists(), filters] as const,
details: () => [...contactKeys.all, 'detail'] as const,
detail: (id: string) =>
[...contactKeys.details(), id] as const,
};
// Usage in queries
export const contactQueryOptions = (id: string) =>
queryOptions({
queryKey: contactKeys.detail(id),
queryFn: () => getContact(id),
});
// Surgical cache invalidation
queryClient.invalidateQueries({
queryKey: contactKeys.all
}); // Invalidates everything
queryClient.invalidateQueries({
queryKey: contactKeys.lists()
}); // Only list queries
Dado que las claves coinciden por prefijo, la jerarquía permite invalidar de forma amplia o limitada: contactKeys.all actualiza todo lo relacionado con los contactos, mientras que contactKeys.lists() afecta únicamente a las consultas de listas y deja intactos los detalles en caché.
Cambiar datos con mutaciones
Un gancho de mutación básico
Las escrituras pasan por useMutation. Al envolverlo en un gancho, se mantienen los efectos secundarios como las notificaciones junto a la solicitud.
export function useDeleteContact() {
return useMutation({
mutationFn: (contactId: string) => deleteContact(contactId),
onSuccess: () => {
toast.success('Contact deleted successfully');
},
onError: () => {
toast.error('Failed to delete contact');
},
});
}
// Usage in components
function ContactCard({ contact }) {
const { mutate, isPending } = useDeleteContact();
return (
<Card>
<h3>{contact.name}</h3>
<button
onClick={() => mutate(contact.id)}
disabled={isPending}
>
{isPending ? 'Deleting...' : 'Delete'}
</button>
</Card>
);
}
onSuccess, onError y onSettled se ejecutan en las etapas correspondientes; isPending facilita desactivar el botón mientras la solicitud está en proceso.
Declarar qué invalida una mutación
Después de una escritura, es necesario volver a obtener las consultas afectadas. En lugar de llamar a invalidateQueries en cada hook, una mutación puede declarar sus objetivos en meta y un único manejador global puede actuar sobre ellos.
// In your mutation
export function useDeleteContact() {
return useMutation({
mutationFn: (contactId: string) => deleteContact(contactId),
meta: {
invalidates: [contactKeys.all],
},
});
}
// Global setup (one time, in main.tsx)
const queryClient = new QueryClient({
defaultOptions: {
mutations: {
onSettled: async (data, error, variables, context) => {
const meta = context?.meta;
if (meta?.invalidates) {
await Promise.all(
meta.invalidates.map((queryKey) =>
queryClient.invalidateQueries({ queryKey })
)
);
}
},
},
},
});
La idea es buena, pero verifica el cableado según tu versión. En la firma del callback mostrada, el cuarto argumento es el valor devuelto por onMutate, no la mutación en sí, por lo que context?.meta no encontrará las claves declaradas. Además, una mutación que define su propio onSettled reemplaza este valor por defecto en lugar de ejecutarse junto a él. Un lugar más adecuado para el manejador es un MutationCache pasado al QueryClient: sus callbacks reciben el objeto de mutación (con mutation.meta) y siempre se ejecutan además de los callbacks específicos para cada mutación. En TypeScript, para tipar meta.invalidates es necesario registrar un tipo Meta personalizado.
Manejo global de errores
Los fallos transversales, como una sesión vencida, también deben estar en un solo lugar.
const queryClient = new QueryClient({
defaultOptions: {
mutations: {
onError: (error) => {
// Handle authentication globally
if (error.status === 401) {
logout();
navigate('/login');
}
// Handle network errors
if (error.message === 'Network Error') {
toast.error('Check your connection');
}
},
},
},
});
Toda mutación comparte entonces la misma respuesta ante un error 401 o una falla de red. Se aplica la misma advertencia sobre sobrescrituras: useDeleteContact define su propio método onError, que reemplaza este valor por defecto, por lo que un método onError de MutationCache sigue siendo la opción fiable. Tenga también en cuenta que error.status y el mensaje 'Network Error' dependen de su cliente HTTP; este último es el mensaje que genera Axios, mientras que fetch lanza un TypeError.
Actualizaciones optimistas
La interfaz de usuario optimista muestra el resultado de una escritura antes de que el servidor lo confirme. Existen dos niveles.
Nivel de la interfaz: ocultar elementos mientras su eliminación está pendiente
useMutationState permite acceder a las mutaciones en curso en cualquier parte del árbol. Al filtrar por clave y estado se obtienen los IDs que se están eliminando en ese momento, y la lista puede ocultarlos simplemente.
function useContactsBeingDeleted() {
return useMutationState({
filters: {
mutationKey: ['deleteContact'],
status: 'pending'
},
select: (mutation) => mutation.state.variables,
});
}
function ContactsList() {
const { data: contacts } = useContacts();
const deletingIds = useContactsBeingDeleted();
// Filter out contacts being deleted
const visibleContacts = contacts.filter(
c => !deletingIds.includes(c.id)
);
return <List items={visibleContacts} />;
}
Nada cambia en la caché, por lo que si la solicitud falla, el elemento vuelve a aparecer por sí solo. Esta correspondencia depende de que la mutación tenga mutationKey: ['deleteContact'], valor que el hook anterior no estableció; hay que agregarlo o de lo contrario el filtro no encontrará nada.
Nivel de caché: editar la caché e revertir en caso de fallo
El enfoque más exhaustivo consiste en editar directamente la lista almacenada en caché, de modo que todos los componentes que la leen se actualicen al mismo tiempo.
export function useDeleteContact() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: deleteContact,
onMutate: async (contactId) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({
queryKey: contactKeys.lists()
});
// Snapshot current value
const previousContacts = queryClient.getQueryData(
contactKeys.lists()
);
// Optimistically update
queryClient.setQueryData(
contactKeys.lists(),
(old) => old.filter(c => c.id !== contactId)
);
// Return rollback data
return { previousContacts };
},
onError: (err, variables, context) => {
// Rollback on error
if (context?.previousContacts) {
queryClient.setQueryData(
contactKeys.lists(),
context.previousContacts
);
}
},
onSettled: () => {
// Always refetch for consistency
queryClient.invalidateQueries({
queryKey: contactKeys.lists()
});
},
});
}
La secuencia es importante. cancelQueries impide que cualquier recarga en curso sobrescriba el cambio optimista. La instantánea se devuelve desde onMutate para que onError pueda restaurarla. onSettled vuelve a obtener los datos independientemente de si la solicitud tuvo éxito o falló, de modo que el caché finalmente coincida con el servidor. También preste atención a la clave: getQueryData y setQueryData son idénticos, por lo que si sus listas están en caché bajo contactKeys.list(filters), apuntar a contactKeys.lists() no afectará nada; setQueriesData actualiza cada entrada bajo un prefijo. Guarde también el valor old, ya que la lista podría no estar aún en caché.
Suspense para estados de carga
useSuspenseQuery garantiza que data esté definido y transfiere el proceso de espera a un límite Suspense de React.
// Change from useQuery to useSuspenseQuery
function ContactsList() {
const { data } = useSuspenseQuery(contactsQueryOptions);
// No isPending check needed!
return <Table data={data} />;
}
function ContactDetails({ id }) {
const { data } = useSuspenseQuery(contactQueryOptions(id));
return <Details contact={data} />;
}
// Centralized loading UI
function App() {
return (
<Suspense fallback={<AppSkeleton />}>
<ContactsList />
<ContactDetails id="123" />
</Suspense>
);
}
Un límite reemplaza a los controladores dispersos por un único esqueleto. El compromiso radica en la granularidad: el límite espera al hijo más lento, por lo que se deben colocar en lugares donde un estado de carga combinado tenga sentido real, y precargar datos para evitar secuencias consecutivas de solicitudes.
Los patrones combinados
Aquí está la función de contactos con todas las partes integradas: una fábrica de claves, opciones parametrizadas, una mutación de eliminación que declara su invalidación y actualiza de forma optimista, y una lista que suspende y precarga datos.
// queries/contacts.ts
export const contactKeys = {
all: ['contacts'] as const,
lists: () => [...contactKeys.all, 'list'] as const,
list: (filters: Filters) => [...contactKeys.lists(), filters] as const,
detail: (id: string) => [...contactKeys.all, id] as const,
};
export const contactsQueryOptions = (filters: Filters) =>
queryOptions({
queryKey: contactKeys.list(filters),
queryFn: () => getContacts(filters),
});
export function useDeleteContact() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: deleteContact,
mutationKey: ['deleteContact'],
meta: { invalidates: [contactKeys.all] },
onMutate: async (contactId) => {
await queryClient.cancelQueries({
queryKey: contactKeys.lists()
});
const previous = queryClient.getQueryData(
contactKeys.lists()
);
queryClient.setQueryData(
contactKeys.lists(),
(old) => old?.filter(c => c.id !== contactId)
);
return { previous };
},
onError: (err, variables, context) => {
if (context?.previous) {
queryClient.setQueryData(
contactKeys.lists(),
context.previous
);
}
},
});
}
// components/ContactsList.tsx
function ContactsList() {
const [page, setPage] = useState(1);
const queryClient = useQueryClient();
const { data } = useSuspenseQuery(
contactsQueryOptions({ page, pageSize: 20 })
);
const { mutate: deleteContact } = useDeleteContact();
// Prefetch next page
useEffect(() => {
queryClient.prefetchQuery(
contactsQueryOptions({ page: page + 1, pageSize: 20 })
);
}, [page, queryClient]);
return (
<Table
data={data.items}
onDelete={deleteContact}
pagination={{ page, onChange: setPage }}
/>
);
}
El resultado es tipado de extremo a extremo, rápido, y mantiene las reglas de caché en un único módulo. Algo a tener en cuenta: con useSuspenseQuery, cambiar page vuelve a suspender el proceso y muestra la alternativa en cada cambio de página; envolver setPage en startTransition mantiene la página actual en pantalla mientras se carga la siguiente.
Puntos clave
- Asigne cada entrada de consulta a su clave; esa única regla evita la mayoría de los errores en el caché.
- Defina las consultas como objetos
queryOptionspara que los ganchos, la precarga y las lecturas del caché compartan una fuente tipada. - Adopte una fábrica de claves desde el principio; el coincidencia por prefijo permite una invalidación precisa.
- Centralice la invalidación y el manejo de errores, preferiblemente en las devoluciones de
MutationCache, para que las devoluciones por mutación no las sobrescriban silenciosamente. - Elija un enfoque optimista a nivel de interfaz para ocultamientos simples y uno a nivel de caché, con capturas de estado y reversión, cuando muchos componentes lean los datos.
Lecturas relacionadas
- React Query y Redux: Repensando el estado del servidor en aplicaciones grandes — Aprenda por qué una aplicación de chat en producción utilizó TanStack Query en lugar de Redux para gestionar los datos del servidor, y dónde sigue teniendo cabida Redux en la arquitectura moderna de React.
- Enviando datos con RTK Query: Una guía práctica sobre mutaciones — Aprenda cómo utilizar builder.mutation() en RTK Query para enviar solicitudes POST, gestionar los estados de carga y error, y crear un componente de formulario funcional.