Saltar al contenido principal

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.

Fran Cobos 5 min de lectura 995 palabras

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.

Fran Cobos

Fran Cobos

Desarrollador Full Stack especializado en IA aplicada, automatización y desarrollo web. Escribo sobre herramientas, tutoriales y casos reales para programadores.

¿Necesitas desarrollo a medida?

Apps web, IA, módulos ERP — cuéntame tu proyecto.