Voltar para Artigos
Front-end10 min de leitura

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.

12 de agosto de 2026

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

QueryProvider.tsx
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.

useUsers.ts
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.

Optimistic update em lista
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

UsersPage.tsx
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.