TanStack Query v5 Tutorial en Español 2026: Data Fetching en React sin Dolor
Domina TanStack Query v5 (React Query) en 2026: useQuery, useMutation, invalidación de caché, paginación y optimistic updates. El tutorial en español más completo.
Tabla de contenidos
TanStack Query v5 cambió suficiente la API respecto a v4 que muchos tutoriales que encuentras están desactualizados. Esta guía es para v5, que es la versión actual en 2026.
Instalación
npm install @tanstack/react-query
npm install -D @tanstack/react-query-devtools # herramientas de debug
Configuración inicial
// main.tsx (o app/layout.tsx en Next.js)
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutos antes de refetch
gcTime: 1000 * 60 * 10, // 10 minutos en caché (antes cacheTime)
retry: 2, // reintentos en error
refetchOnWindowFocus: true, // revalida al volver a la tab
},
},
});
function App() {
return (
<QueryClientProvider client={queryClient}>
<TuApp />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
useQuery: leer datos
Básico
import { useQuery } from '@tanstack/react-query';
// Función de fetch (fuera del componente)
async function getUsuarios(): Promise<Usuario[]> {
const res = await fetch('/api/usuarios');
if (!res.ok) throw new Error('Error al cargar usuarios');
return res.json();
}
function ListaUsuarios() {
const { data: usuarios, isLoading, isError, error } = useQuery({
queryKey: ['usuarios'], // clave de caché única
queryFn: getUsuarios,
});
if (isLoading) return <div>Cargando...</div>;
if (isError) return <div>Error: {error.message}</div>;
return (
<ul>
{usuarios.map(u => (
<li key={u.id}>{u.nombre}</li>
))}
</ul>
);
}
Con parámetros dinámicos
async function getUsuario(id: string): Promise<Usuario> {
const res = await fetch(`/api/usuarios/${id}`);
if (!res.ok) throw new Error('Usuario no encontrado');
return res.json();
}
function PerfilUsuario({ userId }: { userId: string }) {
const { data: usuario, isLoading } = useQuery({
queryKey: ['usuarios', userId], // ← incluye el parámetro en la key
queryFn: () => getUsuario(userId),
enabled: !!userId, // no ejecuta si userId es undefined
});
if (isLoading) return <Skeleton />;
return <div>{usuario?.nombre}</div>;
}
useMutation: crear, actualizar, borrar
import { useMutation, useQueryClient } from '@tanstack/react-query';
async function crearPost(datos: { titulo: string; contenido: string }) {
const res = await fetch('/api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(datos),
});
if (!res.ok) throw new Error('Error al crear post');
return res.json();
}
function FormNuevoPost() {
const queryClient = useQueryClient();
const [titulo, setTitulo] = useState('');
const mutation = useMutation({
mutationFn: crearPost,
onSuccess: (nuevoPost) => {
// Invalida la caché para que se recarguen los posts
queryClient.invalidateQueries({ queryKey: ['posts'] });
// O añade optimistamente a la caché existente
queryClient.setQueryData(['posts'], (old: Post[] = []) => [
...old,
nuevoPost,
]);
setTitulo('');
},
onError: (error) => {
console.error('Error al crear:', error.message);
},
});
return (
<form onSubmit={(e) => {
e.preventDefault();
mutation.mutate({ titulo, contenido: '' });
}}>
<input
value={titulo}
onChange={e => setTitulo(e.target.value)}
placeholder="Título del post"
/>
<button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Creando...' : 'Crear post'}
</button>
{mutation.isError && <p>Error: {mutation.error.message}</p>}
</form>
);
}
Invalidación de caché
const queryClient = useQueryClient();
// Invalida todas las queries con esta key
queryClient.invalidateQueries({ queryKey: ['posts'] });
// Invalida queries específicas (con parámetros)
queryClient.invalidateQueries({ queryKey: ['posts', postId] });
// Invalida todas las queries que empiecen con 'usuario'
queryClient.invalidateQueries({ queryKey: ['usuario'] });
// Actualiza la caché directamente sin refetch
queryClient.setQueryData(['posts', postId], (old: Post) => ({
...old,
titulo: 'Nuevo título',
}));
Paginación
async function getPosts(page: number) {
const res = await fetch(`/api/posts?page=${page}&limit=10`);
if (!res.ok) throw new Error('Error al cargar posts');
return res.json() as Promise<{ posts: Post[]; totalPages: number }>;
}
function ListaPaginada() {
const [page, setPage] = useState(1);
const { data, isLoading, isPlaceholderData } = useQuery({
queryKey: ['posts', page],
queryFn: () => getPosts(page),
placeholderData: (prevData) => prevData, // muestra datos anteriores mientras carga
});
return (
<div>
{isLoading ? (
<Skeleton />
) : (
<ul>
{data?.posts.map(p => <li key={p.id}>{p.titulo}</li>)}
</ul>
)}
<div className="flex gap-2 mt-4">
<button
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page === 1}
>
Anterior
</button>
<span>Página {page} de {data?.totalPages}</span>
<button
onClick={() => setPage(p => p + 1)}
disabled={isPlaceholderData || page === data?.totalPages}
>
Siguiente
</button>
</div>
</div>
);
}
Scroll infinito con useInfiniteQuery
import { useInfiniteQuery } from '@tanstack/react-query';
import { useIntersectionObserver } from '@uidotdev/usehooks';
async function getPostsInfinite({ pageParam = 1 }) {
const res = await fetch(`/api/posts?page=${pageParam}&limit=10`);
return res.json() as Promise<{
posts: Post[];
nextPage: number | null;
}>;
}
function FeedInfinito() {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
queryKey: ['posts', 'infinite'],
queryFn: getPostsInfinite,
initialPageParam: 1,
getNextPageParam: (lastPage) => lastPage.nextPage,
});
const [ref, entry] = useIntersectionObserver({ threshold: 0.1 });
// Auto-cargar al llegar al final
useEffect(() => {
if (entry?.isIntersecting && hasNextPage) {
fetchNextPage();
}
}, [entry, hasNextPage, fetchNextPage]);
const posts = data?.pages.flatMap(p => p.posts) ?? [];
return (
<div>
{posts.map(post => <PostCard key={post.id} post={post} />)}
<div ref={ref}>
{isFetchingNextPage && <Spinner />}
</div>
</div>
);
}
Optimistic Updates
Muestra el resultado al usuario antes de que el servidor confirme:
const mutation = useMutation({
mutationFn: (id: string) => fetch(`/api/posts/${id}`, { method: 'DELETE' }),
onMutate: async (postId) => {
// Cancela queries en vuelo
await queryClient.cancelQueries({ queryKey: ['posts'] });
// Guarda el estado anterior por si hay error
const anteriores = queryClient.getQueryData<Post[]>(['posts']);
// Actualiza optimistamente
queryClient.setQueryData(['posts'], (old: Post[] = []) =>
old.filter(p => p.id !== postId)
);
return { anteriores }; // contexto para el rollback
},
onError: (err, postId, context) => {
// Rollback en caso de error
queryClient.setQueryData(['posts'], context?.anteriores);
},
onSettled: () => {
// Revalida siempre al terminar
queryClient.invalidateQueries({ queryKey: ['posts'] });
},
});
Diferencias clave v4 → v5
// ❌ v4 (ya no funciona)
useQuery(['usuarios'], getUsuarios, { onSuccess: (data) => {...} });
useQuery(['usuarios'], getUsuarios);
// ✅ v5 (todo en objeto)
useQuery({ queryKey: ['usuarios'], queryFn: getUsuarios });
// ❌ v4
const { data } = useQuery(['posts', id], () => getPost(id));
// ✅ v5
const { data } = useQuery({
queryKey: ['posts', id],
queryFn: () => getPost(id),
});
// ❌ v4: cacheTime
// ✅ v5: gcTime
Si usas shadcn/ui para la interfaz, TanStack Query es el complemento natural para el fetching de datos. Combínalo con shadcn/ui para tener una base sólida de UI + data fetching en cualquier proyecto React.