TanStack Query - Gestión de estado del servidor con caché, obtención de datos en segundo plano y actualizaciones optimistas
Receta
npm install @tanstack/react-query// app/providers.tsx
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
export function QueryProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 minuto
refetchOnWindowFocus: false,
},
},
})
);
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}// app/layout.tsx
import { QueryProvider } from "./providers";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<QueryProvider>{children}</QueryProvider>
</body>
</html>
);
}"use client";
import { useQuery } from "@tanstack/react-query";
function usePosts() {
return useQuery({
queryKey: ["posts"],
queryFn: async () => {
const res = await fetch("/api/posts");
if (!res.ok) throw new Error("Error al obtener posts");
return res.json() as Promise<Post[]>;
},
});
}Cuándo usarlo: Necesitas obtención de datos en el cliente con caché automático, obtención de datos en segundo plano, estados de carga/error e invalidación de caché después de mutaciones.
Ejemplo Funcional
// app/components/PostManager.tsx
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
interface Post {
id: number;
title: string;
body: string;
}
function usePosts() {
return useQuery({
queryKey: ["posts"],
queryFn: async (): Promise<Post[]> => {
const res = await fetch("https://jsonplaceholder.typicode.com/posts?_limit=10");
if (!res.ok) throw new Error("Error al obtener");
return res.json();
},
});
}
function useCreatePost() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (newPost: { title: string; body: string }) => {
const res = await fetch("https://jsonplaceholder.typicode.com/posts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(newPost),
});
return res.json() as Promise<Post>;
},
onMutate: async (newPost) => {
// Cancela las consultas en vuelo
await queryClient.cancelQueries({ queryKey: ["posts"] });
// Instantánea del valor anterior
const previousPosts = queryClient.getQueryData<Post[]>(["posts"]);
// Actualización optimista
queryClient.setQueryData<Post[]>(["posts"], (old) => [
{ id: Date.now(), ...newPost } as Post,
...(old ?? []),
]);
return { previousPosts };
},
onError: (_err, _newPost, context) => {
// Reversión ante error
queryClient.setQueryData(["posts"], context?.previousPosts);
},
onSettled: () => {
// Obtén de nuevo para asegurar el estado del servidor
queryClient.invalidateQueries({ queryKey: ["posts"] });
},
});
}
export default function PostManager() {
const { data: posts, isLoading, error } = usePosts();
const createPost = useCreatePost();
if (isLoading) return <div className="p-6">Cargando posts...</div>;
if (error) return <div className="p-6 text-red-600">Error: {error.message}</div>;
return (
<div className="max-w-2xl mx-auto p-6">
<form
onSubmit={(e) => {
e.preventDefault();
const form = e.target as HTMLFormElement;
const title = (form.elements.namedItem("title") as HTMLInputElement).value;
const body = (form.elements.namedItem("body") as HTMLTextAreaElement).value;
createPost.mutate({ title, body });
form.reset();
}}
className="mb-6 space-y-3"
>
<input
name="title"
placeholder="Título del post"
required
className="w-full border rounded px-3 py-2"
/>
<textarea
name="body"
placeholder="Cuerpo del post"
required
rows={3}
className="w-full border rounded px-3 py-2"
/>
<button
type="submit"
disabled={createPost.isPending}
className="bg-blue-600 text-white px-4 py-2 rounded disabled:opacity-50"
>
{createPost.isPending ? "Creando..." : "Crear Post"}
</button>
</form>
<ul className="space-y-3">
{posts?.map((post) => (
<li key={post.id} className="border rounded p-3">
<h3 className="font-bold">{post.title}</h3>
<p className="text-gray-600 text-sm mt-1">{post.body}</p>
</li>
))}
</ul>
</div>
);
}Lo que esto demuestra:
useQuerypara obtención de datos con estados de carga y erroruseMutationcon actualizaciones optimistas y reversión ante error- Invalidación de caché con
invalidateQueriesdespués de mutaciones useQueryClientpara acceder al caché directamente- Hooks personalizados que envuelven consultas para reutilización
Profundización
Cómo Funciona
- TanStack Query gestiona un caché del lado del cliente codificado por arrays
queryKey; las claves idénticas comparten datos en caché staleTimecontrola cuánto tiempo se considera que los datos son frescos; dentro de esta ventana, no se produce obtención de nuevogcTime(anteriormentecacheTime) controla cuánto tiempo se conservan las entradas de caché inactivas (predeterminado 5 minutos)- La obtención de datos en segundo plano ocurre automáticamente al enfocar la ventana, reconectar la red e intervalo (si está configurado)
queryKeyadmite arrays y objetos anidados; las claves se serializan y se comparan profundamente- Las mutaciones no usan el caché; se disparan y olvidan, pero pueden activar actualizaciones de caché a través de callbacks
Variaciones
Consultas dependientes (obtén B solo después de que A se complete):
function useUserPosts(userId: number | undefined) {
return useQuery({
queryKey: ["posts", userId],
queryFn: () => fetch(`/api/users/${userId}/posts`).then((r) => r.json()),
enabled: !!userId, // Solo obtén cuando userId esté disponible
});
}
function UserPosts() {
const { data: user } = useQuery({
queryKey: ["user"],
queryFn: () => fetch("/api/me").then((r) => r.json()),
});
const { data: posts } = useUserPosts(user?.id);
// la consulta de posts espera hasta que el usuario se cargue
}Desplazamiento infinito / paginación:
import { useInfiniteQuery } from "@tanstack/react-query";
function useInfinitePosts() {
return useInfiniteQuery({
queryKey: ["posts", "infinite"],
queryFn: async ({ pageParam }) => {
const res = await fetch(`/api/posts?cursor=${pageParam}&limit=10`);
return res.json() as Promise<{
posts: Post[];
nextCursor: string | null;
}>;
},
initialPageParam: "",
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
}
function InfinitePostList() {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfinitePosts();
const allPosts = data?.pages.flatMap((page) => page.posts) ?? [];
return (
<div>
{allPosts.map((post) => (
<div key={post.id}>{post.title}</div>
))}
{hasNextPage && (
<button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
{isFetchingNextPage ? "Cargando más..." : "Cargar Más"}
</button>
)}
</div>
);
}Precarga para navegación instantánea:
function PostList() {
const queryClient = useQueryClient();
const { data: posts } = usePosts();
return (
<ul>
{posts?.map((post) => (
<li
key={post.id}
onMouseEnter={() => {
queryClient.prefetchQuery({
queryKey: ["post", post.id],
queryFn: () =>
fetch(`/api/posts/${post.id}`).then((r) => r.json()),
});
}}
>
<Link href={`/posts/${post.id}`}>{post.title}</Link>
</li>
))}
</ul>
);
}Hidratación SSR con Next.js App Router:
// app/posts/page.tsx (Server Component)
import {
dehydrate,
HydrationBoundary,
QueryClient,
} from "@tanstack/react-query";
import PostList from "./PostList";
export default async function PostsPage() {
const queryClient = new QueryClient();
await queryClient.prefetchQuery({
queryKey: ["posts"],
queryFn: async () => {
const res = await fetch("https://jsonplaceholder.typicode.com/posts?_limit=10");
return res.json();
},
});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<PostList />
</HydrationBoundary>
);
}Notas de TypeScript
useQuerydeduce el tipo de datos del tipo de retornoqueryFn; anota la función para tipado explícitoqueryKeyaceptareadonly unknown[]; usaas constpara tipos de tupla estrictos- Genéricos
useMutation:useMutation<TData, TError, TVariables, TContext> - La opción
selecttransforma los datos y estrecha el tipo devuelto
// Tipado explícito con select
const { data } = useQuery({
queryKey: ["posts"],
queryFn: async (): Promise<Post[]> => {
const res = await fetch("/api/posts");
return res.json();
},
select: (posts) => posts.filter((p) => p.id > 5), // data es Post[]
});
// Patrón de fábrica de claves de consulta
const postKeys = {
all: ["posts"] as const,
lists: () => [...postKeys.all, "list"] as const,
list: (filters: PostFilters) => [...postKeys.lists(), filters] as const,
details: () => [...postKeys.all, "detail"] as const,
detail: (id: number) => [...postKeys.details(), id] as const,
};Trampas
-
QueryClient en Server Component — Crear
QueryClientfuera de un componente o en un Server Component causa estado compartido entre solicitudes. Solución: CreaQueryClientdentro deuseStateen el proveedor, o crea una nueva instancia por solicitud en funciones de precarga de servidor. -
Confusión staleTime vs gcTime —
staleTimecontrola el comportamiento de obtención de nuevo;gcTimecontrola la evicción de caché. EstablecerstaleTime: Infinityevita obtenciones de nuevo pero el caché aún puede ser recolectado basura. Solución: Entiende ambos:staleTime= "¿cuánto tiempo se considera que los datos son frescos?"gcTime= "¿cuánto tiempo mantener las entradas de caché inactivas?" -
La clave de consulta debe ser serializable — Las funciones, instancias de clase o referencias circulares en las claves de consulta causan problemas. Solución: Usa solo strings, números, objetos y arrays en las claves de consulta.
-
Las mutaciones no invalidan automáticamente — A diferencia de algunos marcos, las mutaciones no obtienen automáticamente de nuevo las consultas relacionadas. Solución: Usa callback
onSettleduonSuccessconqueryClient.invalidateQueries(). -
Doble obtención en Strict Mode — React Strict Mode en desarrollo monta componentes dos veces, causando dos obtenciones. Solución: Este es el comportamiento esperado solo en modo desarrollo. En producción, las consultas se obtienen una vez. TanStack Query deduplica solicitudes idénticas concurrentes.
-
Desajuste de tipo de actualización optimista — La forma de datos optimistas debe coincidir exactamente con los datos de consulta. Solución: Usa
queryClient.getQueryData<Post[]>()con el tipo genérico correcto, y asegúrate de que los elementos optimistas tengan todos los campos requeridos.
Alternativas
| Librería | Mejor Para | Compensación |
|---|---|---|
| TanStack Query | Obtención de datos del lado del cliente compleja | Dependencia adicional, curva de aprendizaje |
| SWR | Obtención de datos simple con caché | Menos características (sin mutaciones, sin devtools) |
| Next.js Server Components | Obtención de datos en el lado del servidor | Sin caché del lado del cliente, sin obtención de datos en segundo plano |
| RTK Query | Aplicaciones basadas en Redux | Vinculado a Redux, configuración más pesada |
| Apollo Client | APIs GraphQL | Excesivo para REST, paquete más grande |
Preguntas Frecuentes
¿Qué es queryKey y por qué importa?
queryKeyes un array que identifica únicamente los datos en caché de una consulta- Las claves idénticas comparten la misma entrada de caché y deduplicar solicitudes concurrentes
- Las claves se serializan y se comparan profundamente, así que
["posts", { status: "draft" }]funciona - Cambiar la clave activa una nueva obtención
¿Cuál es la diferencia entre staleTime y gcTime?
staleTime= cuánto tiempo se considera que los datos son frescos (sin obtención de nuevo durante esta ventana)gcTime(anteriormentecacheTime) = cuánto tiempo se conservan las entradas de caché inactivas antes de la recolección de basura- Establecer
staleTime: Infinityevita todas las obtenciones automáticas de nuevo - El
gcTimepredeterminado es 5 minutos; después de eso, los datos de consulta desmontados se eliminan
¿Cómo invalido el caché después de una mutación?
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: createPost,
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["posts"] });
},
});Usa invalidateQueries en onSettled u onSuccess para obtener de nuevo datos obsoletos.
¿Cómo funcionan las actualizaciones optimistas con useMutation?
onMutate: cancela consultas en vuelo, instantánea de datos actuales, actualiza caché optimistamenteonError: revierte a la instantánea si la mutación fallaonSettled: invalida consultas para obtener de nuevo del servidor independientemente del éxito/fracaso- La forma de datos optimistas debe coincidir exactamente con los datos de consulta
Trampa: ¿Por qué crear QueryClient fuera de un componente causa estado compartido entre solicitudes?
- Un
QueryClienta nivel de módulo se comparte entre todas las solicitudes del servidor - Los datos de un usuario podrían filtrarse hacia la respuesta de otro usuario
- Solución: crea
QueryClientdentro deuseStateen el componente proveedor - Para precarga de servidor, crea una nueva instancia por solicitud
¿Cómo implemento consultas dependientes (obtén B solo después de A)?
const { data: user } = useQuery({
queryKey: ["user"],
queryFn: fetchUser,
});
const { data: posts } = useQuery({
queryKey: ["posts", user?.id],
queryFn: () => fetchUserPosts(user!.id),
enabled: !!user?.id,
});La opción enabled evita que la consulta se ejecute hasta que la dependencia esté disponible.
¿Cómo configuro la hidratación SSR con Next.js App Router?
- Precarga consultas en un Server Component usando un nuevo
QueryClient - Llama a
dehydrate(queryClient)para serializar el caché - Envuelve el componente cliente con
<HydrationBoundary state={dehydratedState}> - Los componentes cliente que usan
useQuerycon la misma clave obtienen datos instantáneos
¿Cómo implemento desplazamiento infinito con useInfiniteQuery?
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey: ["posts", "infinite"],
queryFn: ({ pageParam }) =>
fetch(`/api/posts?cursor=${pageParam}`).then(r => r.json()),
initialPageParam: "",
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
const allPosts = data?.pages.flatMap(p => p.posts) ?? [];Trampa: ¿Por qué veo doble obtención en modo desarrollo?
- React Strict Mode monta componentes dos veces en desarrollo, activando dos obtenciones
- Esto solo ocurre en modo desarrollo, no en producción
- TanStack Query deduplica automáticamente solicitudes idénticas concurrentes
- No desactives Strict Mode para evitar esto
¿Cómo escribo correctamente useQuery y useMutation en TypeScript?
// useQuery deduce el tipo de datos del tipo de retorno queryFn
const { data } = useQuery({
queryKey: ["posts"],
queryFn: async (): Promise<Post[]> => {
const res = await fetch("/api/posts");
return res.json();
},
});
// data es Post[] | undefined
// Genéricos useMutation: <TData, TError, TVariables, TContext>
const mutation = useMutation<Post, Error, { title: string }>({
mutationFn: (vars) => createPost(vars),
});¿Qué es el patrón de fábrica de claves de consulta y por qué usarlo?
const postKeys = {
all: ["posts"] as const,
lists: () => [...postKeys.all, "list"] as const,
list: (filters: Filters) => [...postKeys.lists(), filters] as const,
detail: (id: number) => [...postKeys.all, "detail", id] as const,
};- Centraliza definiciones de claves para evitar errores tipográficos e inconsistencias
- Hace que la invalidación sea más fácil:
invalidateQueries({ queryKey: postKeys.all })borra todas las consultas de posts as constproporciona tipos de tupla estrictos para una mejor inferencia de TypeScript
¿Cómo precargo datos al pasar el mouse para navegación instantánea?
<li
onMouseEnter={() => {
queryClient.prefetchQuery({
queryKey: ["post", post.id],
queryFn: () => fetchPost(post.id),
});
}}
>
<Link href={`/posts/${post.id}`}>{post.title}</Link>
</li>Los datos se cachean antes de que el usuario haga clic, haciendo que la carga de la siguiente página sea instantánea.
Relacionado
- SWR — Alternativa más ligera para casos de uso más simples
- Next.js Server Components — Obtención de datos del lado del servidor antes de la hidratación
- Zustand — Gestión de estado del cliente (separada del estado del servidor)
- Prisma — Acceso a base de datos para los puntos finales de API que TanStack Query llama