Busca en todas las páginas de la documentación
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
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.
// 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:
useQuery para obtención de datos con estados de carga y erroruseMutation con actualizaciones optimistas y reversión ante errorinvalidateQueries después de mutacionesuseQueryClient para acceder al caché directamentequeryKey; las claves idénticas comparten datos en cachéstaleTime controla cuánto tiempo se considera que los datos son frescos; dentro de esta ventana, no se produce obtención de nuevogcTime (anteriormente cacheTime) controla cuánto tiempo se conservan las entradas de caché inactivas (predeterminado 5 minutos)queryKey admite arrays y objetos anidados; las claves se serializan y se comparan profundamenteConsultas 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>
);
}useQuery deduce el tipo de datos del tipo de retorno queryFn; anota la función para tipado explícitoqueryKey acepta readonly unknown[]; usa as const para tipos de tupla estrictosuseMutation: useMutation<TData, TError, TVariables, TContext>select transforma 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,
};QueryClient en Server Component - Crear QueryClient fuera de un componente o en un Server Component causa estado compartido entre solicitudes. Solución: Crea QueryClient dentro de useState en el proveedor, o crea una nueva instancia por solicitud en funciones de precarga de servidor.
Confusión staleTime vs gcTime - staleTime controla el comportamiento de obtención de nuevo; gcTime controla la evicción de caché. Establecer staleTime: Infinity evita 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 onSettled u onSuccess con queryClient.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.
| 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 |
queryKey es un array que identifica únicamente los datos en caché de una consulta["posts", { status: "draft" }] funcionastaleTime = cuánto tiempo se considera que los datos son frescos (sin obtención de nuevo durante esta ventana)gcTime (anteriormente cacheTime) = cuánto tiempo se conservan las entradas de caché inactivas antes de la recolección de basurastaleTime: Infinity evita todas las obtenciones automáticas de nuevogcTime predeterminado es 5 minutos; después de eso, los datos de consulta desmontados se eliminanconst queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: createPost,
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["posts"] });
},
});Usa invalidateQueries en onSettled u onSuccess para obtener de nuevo datos obsoletos.
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/fracasoQueryClient a nivel de módulo se comparte entre todas las solicitudes del servidorQueryClient dentro de useState en el componente proveedorconst { 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.
QueryClientdehydrate(queryClient) para serializar el caché<HydrationBoundary state={dehydratedState}>useQuery con la misma clave obtienen datos instantáneosconst { 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) ?? [];// 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),
});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,
};invalidateQueries({ queryKey: postKeys.all }) borra todas las consultas de postsas const proporciona tipos de tupla estrictos para una mejor inferencia de TypeScript<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.
Revisado por Chris St. John·Última actualización: 16 jul 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥