A Morte do useEffect: Data Fetching com TanStack Query
Server State vs Client State. useQuery com queryKey tipada, useMutation com invalidation automático, prefetch no servidor (Next.js SSR), optimistic updates e o padrão de custom hooks por domínio.
O padrão useEffect + useState para data fetching tem problemas conhecidos: race conditions quando o componente desmonta antes da resposta chegar, nenhum cache entre navegações, nenhum retry automático, re-fetch redundante de múltiplos componentes para a mesma query, e boilerplate repetitivo para cada endpoint. O useEffect não foi projetado para data fetching — foi projetado para sincronizar com sistemas externos.
TanStack Query (React Query) é uma biblioteca de Server State Management: ela entende que dados do servidor têm ciclo de vida diferente do estado local. Cache inteligente, deduplicação automática de requests, sincronização em background, retry com backoff e invalidação declarativa resolvem todos esses problemas.
Setup
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
// Configuração global — ajuste por projeto
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // Dados são frescos por 5 minutos
gcTime: 10 * 60 * 1000, // Cache mantido por 10 minutos sem uso
retry: 2, // 2 retries em caso de falha de rede
refetchOnWindowFocus: true, // Atualiza quando o usuário volta à aba
refetchOnReconnect: true, // Atualiza após reconexão
},
mutations: {
retry: 0, // Mutations não fazem retry automático (operações de escrita)
},
},
});
export function QueryProvider({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
{children}
{/* DevTools: visível apenas em desenvolvimento */}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}Custom Hooks por Domínio
A configuração do QueryClient define o comportamento global: staleTime de 5 minutos significa que o React Query não vai refazer a requisição dentro desse período (dados são considerados frescos). Quando o staleTime vence, a próxima renderização dispara o refetch em background — o usuário não viu loading, viu os dados antigos e recebeu os novos silenciosamente. O refetchOnWindowFocus garante que voltar ao browser depois de minutos traz dados atualizados automaticamente.
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '../services/api';
import type { User, CreateUserDTO } from '../types';
// QueryKey factory: centraliza as chaves para reutilização e invalidação
const userKeys = {
all: ['users'] as const,
lists: () => [...userKeys.all, 'list'] as const,
list: (params: Record<string, unknown>) => [...userKeys.lists(), params] as const,
detail: (id: string) => [...userKeys.all, 'detail', id] as const,
};
// useQuery: leitura de dados com cache
export function useUsers(params: { page: number; search?: string }) {
return useQuery({
queryKey: userKeys.list(params), // Muda com os params → nova query por filtro
queryFn: async () => {
const { data } = await api.get<{ data: Array<User>; meta: PaginationMeta }>('/users', {
params,
});
return data;
},
placeholderData: (prev) => prev, // Mantém dados anteriores enquanto carrega nova página
});
}
export function useUser(id: string) {
return useQuery({
queryKey: userKeys.detail(id),
queryFn: async () => {
const { data } = await api.get<User>(`/users/${id}`);
return data;
},
enabled: !!id, // Não executa se id for vazio/undefined
});
}
// useMutation: operações de escrita com invalidação automática
export function useCreateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (dto: CreateUserDTO) => {
const { data } = await api.post<User>('/users', dto);
return data;
},
onSuccess: (newUser) => {
// Invalida toda a lista de usuários — React Query vai refetch automático
queryClient.invalidateQueries({ queryKey: userKeys.lists() });
// Opcional: pré-popula o cache do detalhe do novo usuário
queryClient.setQueryData(userKeys.detail(newUser.id), newUser);
},
onError: (error) => {
// Tratamento de erro centralizado
console.error('Falha ao criar usuário:', error);
},
});
}Optimistic Updates
O padrão de QueryKey factory (userKeys.all, userKeys.list(params), userKeys.detail(id)) é fundamental para invalidação precisa. Quando um usuário é criado e chamamos invalidateQueries({ queryKey: userKeys.lists() }), invalidamos todas as listagens (com qualquer filtro/página), mas não afetamos as queries de detalhe. Sem factory, a invalidação vira string magic espalhada pelo código.
export function useToggleUserActive() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (userId: string) => api.patch(`/users/${userId}/toggle-active`),
// onMutate: atualiza o cache ANTES da requisição terminar
// Usuário vê o feedback instantâneo, sem esperar a API
onMutate: async (userId) => {
// Cancela queries em andamento para evitar conflito
await queryClient.cancelQueries({ queryKey: userKeys.lists() });
// Snapshot do estado anterior (para rollback em caso de erro)
const previousUsers = queryClient.getQueryData(userKeys.lists());
// Atualiza o cache otimisticamente
queryClient.setQueriesData(
{ queryKey: userKeys.lists() },
(old: { data: Array<User> } | undefined) => ({
...old,
data: old?.data.map((u) =>
u.id === userId ? { ...u, active: !u.active } : u
),
})
);
// Retorna contexto para o onError
return { previousUsers };
},
// onError: reverte o cache se a mutation falhar
onError: (_err, _userId, context) => {
queryClient.setQueryData(userKeys.lists(), context?.previousUsers);
},
// onSettled: sempre sincroniza com o servidor após a mutation
onSettled: () => {
queryClient.invalidateQueries({ queryKey: userKeys.lists() });
},
});
}Uso nos Componentes
import { useState } from 'react';
import { useUsers, useCreateUser } from '../hooks/useUsers';
export function UsersPage() {
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const { data, isLoading, isFetching, error } = useUsers({ page, search });
const createUser = useCreateUser();
if (isLoading) return <UserListSkeleton />;
if (error) return <ErrorState error={error} />;
return (
<div>
{/* isFetching: true durante refetch em background (lista não some) */}
{isFetching && <div className="h-1 bg-indigo-500 animate-pulse" />}
<ul>
{data?.data.map((user) => <UserCard key={user.id} user={user} />)}
</ul>
<Pagination
currentPage={page}
totalPages={data?.meta.totalPages ?? 1}
onPageChange={setPage}
/>
<button
onClick={() => createUser.mutate({ name: 'Novo', email: 'novo@test.com', password: '123' })}
disabled={createUser.isPending}
>
{createUser.isPending ? 'Criando...' : 'Criar usuário'}
</button>
</div>
);
}Server State vs Client State: Dados que vêm da API (usuários, produtos, pedidos) → TanStack Query. Estado de UI local (modal aberto, tab ativa, filtros temporários) → useState ou Zustand. Estado compartilhado entre componentes sem origem em API (carrinho, preferências do usuário antes de salvar) → Zustand ou Context API. Não coloque Server State no Redux/Zustand — é redundância com o cache do React Query.
Conclusão
TanStack Query elimina o boilerplate de data fetching e resolve problemas que você nem sabia que tinha: race conditions, requests duplicados, stale data, sincronização em background e falta de cache entre navegações. O padrão de custom hooks por domínio (useUsers, useProducts) encapsula a lógica de fetching e mantém os componentes focados apenas na apresentação.