JB
_
·5 min de leitura

React Query em 2026: O Padrão Definitivo para Data Fetching

Domine TanStack Query com exemplos práticos de queries, mutations, cache inteligente, infinite scroll, optimistic updates e integração com Next.js App Router.

ReactTypeScriptTanStack QueryNext.js

O Problema do Data Fetching Manual

Quantas vezes você escreveu esse padrão?

// ❌ O jeito sofrido
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
 
useEffect(() => {
  setLoading(true);
  fetch('/api/users')
    .then(r => r.json())
    .then(setData)
    .catch(setError)
    .finally(() => setLoading(false));
}, []);

Sem cache, sem deduplicação de requests, sem revalidação em background, sem retry automático. TanStack Query (React Query) resolve tudo isso.

Setup

npm install @tanstack/react-query @tanstack/react-query-devtools
// app/providers.tsx
'use client';
 
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { useState } from 'react';
 
export function Providers({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(() => new QueryClient({
    defaultOptions: {
      queries: {
        staleTime: 1000 * 60 * 5,   // 5 minutos
        gcTime: 1000 * 60 * 10,     // 10 minutos
        retry: 2,
        refetchOnWindowFocus: false,
      },
    },
  }));
 
  return (
    <QueryClientProvider client={queryClient}>
      {children}
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
}

Queries — O Básico

// lib/api.ts — centralize as chamadas
async function getUsers(): Promise<User[]> {
  const res = await fetch('/api/users');
  if (!res.ok) throw new Error('Falha ao buscar usuários');
  return res.json();
}
 
async function getUserById(id: string): Promise<User> {
  const res = await fetch(`/api/users/${id}`);
  if (!res.ok) throw new Error('Usuário não encontrado');
  return res.json();
}
// hooks/useUsers.ts
import { useQuery } from '@tanstack/react-query';
 
export const userKeys = {
  all: ['users'] as const,
  detail: (id: string) => ['users', id] as const,
};
 
export function useUsers() {
  return useQuery({
    queryKey: userKeys.all,
    queryFn: getUsers,
  });
}
 
export function useUser(id: string) {
  return useQuery({
    queryKey: userKeys.detail(id),
    queryFn: () => getUserById(id),
    enabled: !!id, // só executa se id existir
  });
}
// components/UserList.tsx
export function UserList() {
  const { data: users, isLoading, isError, error } = useUsers();
 
  if (isLoading) return <Skeleton />;
  if (isError) return <ErrorMessage message={error.message} />;
 
  return (
    <ul>
      {users.map(user => <UserCard key={user.id} user={user} />)}
    </ul>
  );
}

Mutations — Criando, Atualizando e Deletando

// hooks/useCreateUser.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
 
async function createUser(data: CreateUserDTO): Promise<User> {
  const res = await fetch('/api/users', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(data),
  });
  if (!res.ok) throw new Error('Falha ao criar usuário');
  return res.json();
}
 
export function useCreateUser() {
  const queryClient = useQueryClient();
 
  return useMutation({
    mutationFn: createUser,
    onSuccess: (newUser) => {
      // Invalidar o cache para refetch automático
      queryClient.invalidateQueries({ queryKey: userKeys.all });
 
      // Ou inserir diretamente no cache (mais rápido, sem request)
      queryClient.setQueryData<User[]>(userKeys.all, (old = []) => [
        ...old,
        newUser,
      ]);
    },
    onError: (error) => {
      toast.error(`Erro: ${error.message}`);
    },
  });
}
// components/CreateUserForm.tsx
export function CreateUserForm() {
  const { mutate, isPending } = useCreateUser();
 
  const onSubmit = (data: CreateUserDTO) => {
    mutate(data, {
      onSuccess: () => toast.success('Usuário criado!'),
    });
  };
 
  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      {/* ... */}
      <button type="submit" disabled={isPending}>
        {isPending ? 'Criando...' : 'Criar Usuário'}
      </button>
    </form>
  );
}

Optimistic Updates

Atualiza a UI antes do servidor confirmar — experiência instantânea:

export function useDeleteUser() {
  const queryClient = useQueryClient();
 
  return useMutation({
    mutationFn: (userId: string) =>
      fetch(`/api/users/${userId}`, { method: 'DELETE' }).then(r => {
        if (!r.ok) throw new Error('Falha ao deletar');
      }),
 
    onMutate: async (userId) => {
      // Cancela refetches pendentes
      await queryClient.cancelQueries({ queryKey: userKeys.all });
 
      // Snapshot do estado atual
      const previousUsers = queryClient.getQueryData<User[]>(userKeys.all);
 
      // Atualiza o cache otimisticamente
      queryClient.setQueryData<User[]>(userKeys.all, (old = []) =>
        old.filter(u => u.id !== userId)
      );
 
      // Retorna contexto para rollback
      return { previousUsers };
    },
 
    onError: (err, userId, context) => {
      // Rollback em caso de erro
      if (context?.previousUsers) {
        queryClient.setQueryData(userKeys.all, context.previousUsers);
      }
      toast.error('Erro ao deletar usuário');
    },
 
    onSettled: () => {
      // Sempre sincroniza com o servidor ao final
      queryClient.invalidateQueries({ queryKey: userKeys.all });
    },
  });
}

Infinite Scroll com useInfiniteQuery

export function useInfiniteUsers() {
  return useInfiniteQuery({
    queryKey: ['users', 'infinite'],
    queryFn: ({ pageParam }) =>
      fetch(`/api/users?page=${pageParam}&limit=20`).then(r => r.json()),
    initialPageParam: 1,
    getNextPageParam: (lastPage, allPages) =>
      lastPage.hasMore ? allPages.length + 1 : undefined,
  });
}
 
// Componente
export function InfiniteUserList() {
  const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteUsers();
  const ref = useRef<HTMLDivElement>(null);
 
  // Intersection Observer para carregar ao chegar no final
  useEffect(() => {
    const observer = new IntersectionObserver(
      ([entry]) => { if (entry.isIntersecting && hasNextPage) fetchNextPage(); },
      { threshold: 0.5 }
    );
    if (ref.current) observer.observe(ref.current);
    return () => observer.disconnect();
  }, [hasNextPage, fetchNextPage]);
 
  return (
    <div>
      {data?.pages.flatMap(page => page.users).map(user => (
        <UserCard key={user.id} user={user} />
      ))}
      <div ref={ref}>
        {isFetchingNextPage && <Spinner />}
      </div>
    </div>
  );
}

Prefetch no Next.js (Server Side)

// app/users/page.tsx (Server Component)
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
 
export default async function UsersPage() {
  const queryClient = new QueryClient();
 
  // Prefetch no servidor — dados chegam hidratados no cliente
  await queryClient.prefetchQuery({
    queryKey: userKeys.all,
    queryFn: getUsers,
  });
 
  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <UserList />  {/* já tem os dados, sem loading state */}
    </HydrationBoundary>
  );
}

Conclusão

TanStack Query não é só um fetcher — é um servidor de estado assíncrono. Cache, deduplicação, background sync, retry, optimistic updates, prefetch: features que levariam semanas para implementar do zero chegam prontas e testadas.

Se você usa useState + useEffect para buscar dados em 2026, está trabalhando em modo hard. Dê uma chance ao React Query — você não vai querer voltar.