React Query (TanStack): Data Fetching Patterns

Master TanStack Query v5 data fetching patterns: queries, mutations, optimistic updates, infinite scroll, prefetching, cache invalidation, and integration with Next.js App Router.

E
ECOSIRE Research and Development Team
|19 de março de 202610 min de leitura2.2k Palavras|

Parte da nossa série Performance & Scalability

Leia o guia completo

React Query (TanStack): Padrões de busca de dados

A busca de dados no React tem uma área de superfície surpreendentemente grande: estados de carregamento, estados de erro, busca em segundo plano, invalidação de cache após mutações, atualizações otimistas, rolagem infinita, pré-busca e desduplicação. Escrever essa lógica manualmente com useEffect + useState leva a condições de corrida, vazamentos de memória e estados de interface do usuário inconsistentes. TanStack Query (anteriormente React Query) v5 resolve tudo isso com uma API limpa e combinável.

Este guia cobre os padrões TanStack Query v5 para aplicativos React de produção - desde consultas básicas até fluxos de mutação complexos, atualizações otimistas, rolagem infinita e integração do Next.js App Router. Cada padrão inclui TypeScript e tratamento de erros.

Principais conclusões

  • TanStack Query é uma biblioteca de estado de servidor — não a use para estado de UI somente cliente (use Zustand ou useState para isso)
  • As chaves de consulta são o endereço do cache — incluem todas as variáveis que afetam o resultado da consulta
  • staleTime controla quando a busca em segundo plano é acionada; gcTime controla quando os dados não utilizados são coletados como lixo
  • Mutações devem invalidar consultas relacionadas via queryClient.invalidateQueries() em onSuccess
  • Atualizações otimistas melhoram o desempenho percebido, mas exigem lógica de reversão em onError
  • Use useInfiniteQuery para listas paginadas; nunca combine useQuery + estado da página de manual
  • Pré-busca ao passar o mouse para uma sensação de navegação instantânea: queryClient.prefetchQuery()
  • No Next.js App Router, use HydrationBoundary para desidratar os dados obtidos pelo servidor no cliente

Instalação e configuração

pnpm add @tanstack/react-query @tanstack/react-query-devtools
// src/providers/query-provider.tsx
'use client';

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { useState } from 'react';

export function QueryProvider({ children }: { children: React.ReactNode }) {
  // Create QueryClient inside component to avoid shared state between requests (SSR safety)
  const [queryClient] = useState(() =>
    new QueryClient({
      defaultOptions: {
        queries: {
          staleTime:            60 * 1000, // 1 minute — data is fresh for 1 minute
          gcTime:           5 * 60 * 1000, // 5 minutes — keep unused cache for 5 minutes
          retry:                        2, // Retry failed requests twice
          retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 30_000), // Exponential backoff
          refetchOnWindowFocus:      true, // Refetch when user returns to tab
          refetchOnReconnect:        true, // Refetch after network reconnect
        },
        mutations: {
          retry: 0, // Do not retry mutations (non-idempotent)
        },
      },
    })
  );

  return (
    <QueryClientProvider client={queryClient}>
      {children}
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
}
// src/app/[locale]/layout.tsx
import { QueryProvider } from '@/providers/query-provider';

export default function LocaleLayout({ children }: { children: React.ReactNode }) {
  return (
    <QueryProvider>
      {children}
    </QueryProvider>
  );
}

Chaves de consulta — A base

As chaves de consulta são o endereço de cache dos seus dados. Cada variável que afeta o resultado da consulta deve estar na chave.

// src/lib/query-keys.ts
// Centralize query keys to avoid typos and enable easy invalidation
export const queryKeys = {
  contacts: {
    all:          () => ['contacts'] as const,
    lists:        () => [...queryKeys.contacts.all(), 'list'] as const,
    list:         (filters: ContactFilters) =>
                    [...queryKeys.contacts.lists(), filters] as const,
    details:      () => [...queryKeys.contacts.all(), 'detail'] as const,
    detail:       (id: string) =>
                    [...queryKeys.contacts.details(), id] as const,
  },
  orders: {
    all:    () => ['orders'] as const,
    list:   (orgId: string, page: number) => ['orders', orgId, page] as const,
    detail: (id: string) => ['orders', id] as const,
  },
} as const;

// Usage in queries and invalidations:
// useQuery({ queryKey: queryKeys.contacts.list({ page: 1, limit: 20 }) })
// queryClient.invalidateQueries({ queryKey: queryKeys.contacts.lists() })
// — invalidates ALL list queries regardless of filter parameters

Consultas básicas

// src/hooks/use-contacts.ts
import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '@/lib/api';
import { queryKeys } from '@/lib/query-keys';

interface ContactFilters {
  page: number;
  limit: number;
  search?: string;
  status?: string;
}

interface ContactsResponse {
  data: Contact[];
  total: number;
  page: number;
  limit: number;
}

export function useContacts(filters: ContactFilters) {
  return useQuery({
    queryKey: queryKeys.contacts.list(filters),
    queryFn: async () => {
      const params = new URLSearchParams({
        page: String(filters.page),
        limit: String(filters.limit),
        ...(filters.search && { search: filters.search }),
        ...(filters.status && { status: filters.status }),
      });
      return apiFetch<ContactsResponse>(`/contacts?${params}`);
    },
    placeholderData: (previousData) => previousData, // Keep previous page data while fetching
    staleTime: 30_000, // Contacts data: 30 second stale time
  });
}

export function useContact(id: string) {
  return useQuery({
    queryKey: queryKeys.contacts.detail(id),
    queryFn: () => apiFetch<Contact>(`/contacts/${id}`),
    enabled: !!id, // Only fetch if id is provided
  });
}
// Usage in a component
'use client';

export default function ContactsPage() {
  const [page, setPage] = useState(1);
  const [search, setSearch] = useState('');

  const { data, isLoading, isError, error, isFetching } = useContacts({
    page,
    limit: 20,
    search: search || undefined,
  });

  if (isLoading) return <ContactsSkeleton />;
  if (isError) return <ErrorState message={error.message} />;

  return (
    <div>
      {isFetching && <RefetchIndicator />} {/* Shows during background refetch */}
      <ContactsTable data={data.data} />
      <Pagination
        page={page}
        total={data.total}
        limit={20}
        onChange={setPage}
      />
    </div>
  );
}

Mutações com invalidação de cache

// src/hooks/use-contact-mutations.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { apiFetch } from '@/lib/api';
import { queryKeys } from '@/lib/query-keys';
import { toast } from '@/components/ui/toast';

export function useCreateContact() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (data: CreateContactDto) =>
      apiFetch<Contact>('/contacts', {
        method: 'POST',
        body: JSON.stringify(data),
      }),

    onSuccess: (newContact) => {
      // Invalidate all contact list queries — they are now stale
      queryClient.invalidateQueries({
        queryKey: queryKeys.contacts.lists(),
      });

      // Seed the detail cache immediately — no need to refetch
      queryClient.setQueryData(
        queryKeys.contacts.detail(newContact.id),
        newContact
      );

      toast({ title: 'Contact created', variant: 'success' });
    },

    onError: (error: Error) => {
      toast({ title: 'Failed to create contact', description: error.message, variant: 'destructive' });
    },
  });
}

export function useDeleteContact() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (id: string) =>
      apiFetch(`/contacts/${id}`, { method: 'DELETE' }),

    onSuccess: (_data, deletedId) => {
      // Remove from all list queries
      queryClient.invalidateQueries({ queryKey: queryKeys.contacts.lists() });
      // Remove detail cache
      queryClient.removeQueries({ queryKey: queryKeys.contacts.detail(deletedId) });
      toast({ title: 'Contact deleted', variant: 'success' });
    },
  });
}

Atualizações otimistas

Atualizações otimistas fazem com que as mutações pareçam instantâneas, atualizando a IU antes que o servidor confirme:

export function useUpdateContact() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: ({ id, data }: { id: string; data: UpdateContactDto }) =>
      apiFetch<Contact>(`/contacts/${id}`, {
        method: 'PATCH',
        body: JSON.stringify(data),
      }),

    // Called immediately before mutationFn
    onMutate: async ({ id, data }) => {
      // Cancel any in-flight refetches for this contact
      await queryClient.cancelQueries({ queryKey: queryKeys.contacts.detail(id) });

      // Snapshot the previous value for rollback
      const previousContact = queryClient.getQueryData<Contact>(
        queryKeys.contacts.detail(id)
      );

      // Optimistically update the UI
      queryClient.setQueryData<Contact>(
        queryKeys.contacts.detail(id),
        (old) => old ? { ...old, ...data } : old
      );

      return { previousContact }; // Passed to onError context
    },

    onError: (_error, { id }, context) => {
      // Roll back to the snapshot on error
      if (context?.previousContact) {
        queryClient.setQueryData(
          queryKeys.contacts.detail(id),
          context.previousContact
        );
      }
      toast({ title: 'Update failed', variant: 'destructive' });
    },

    onSettled: (_data, _error, { id }) => {
      // Always refetch after success or error to sync with server truth
      queryClient.invalidateQueries({ queryKey: queryKeys.contacts.detail(id) });
    },
  });
}

Rolagem/Paginação Infinita

// src/hooks/use-infinite-contacts.ts
import { useInfiniteQuery } from '@tanstack/react-query';

export function useInfiniteContacts(search?: string) {
  return useInfiniteQuery({
    queryKey: ['contacts', 'infinite', search],
    queryFn: ({ pageParam = 1 }) =>
      apiFetch<ContactsResponse>(`/contacts?page=${pageParam}&limit=20${search ? `&search=${search}` : ''}`),

    initialPageParam: 1,
    getNextPageParam: (lastPage) => {
      const nextPage = lastPage.page + 1;
      const maxPage = Math.ceil(lastPage.total / lastPage.limit);
      return nextPage <= maxPage ? nextPage : undefined;
    },

    staleTime: 30_000,
  });
}
// Infinite scroll component
'use client';

import { useInfiniteContacts } from '@/hooks/use-infinite-contacts';
import { useInView } from 'react-intersection-observer';
import { useEffect } from 'react';

export function InfiniteContactsList() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    isLoading,
  } = useInfiniteContacts();

  const { ref, inView } = useInView({ threshold: 0.5 });

  // Fetch next page when the sentinel element enters the viewport
  useEffect(() => {
    if (inView && hasNextPage && !isFetchingNextPage) {
      fetchNextPage();
    }
  }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);

  if (isLoading) return <ContactsSkeleton />;

  const contacts = data?.pages.flatMap((page) => page.data) ?? [];

  return (
    <div>
      {contacts.map((contact) => (
        <ContactCard key={contact.id} contact={contact} />
      ))}

      {/* Sentinel element — triggers next page load when visible */}
      <div ref={ref} className="h-4">
        {isFetchingNextPage && <LoadingSpinner />}
      </div>
    </div>
  );
}

Pré-busca para navegação instantânea

// Prefetch on hover — data is ready before the user clicks
'use client';

import { useQueryClient } from '@tanstack/react-query';
import { Link } from '@/i18n/navigation';
import { queryKeys } from '@/lib/query-keys';
import { apiFetch } from '@/lib/api';

export function ContactRow({ contact }: { contact: Contact }) {
  const queryClient = useQueryClient();

  const handleMouseEnter = () => {
    queryClient.prefetchQuery({
      queryKey: queryKeys.contacts.detail(contact.id),
      queryFn: () => apiFetch<Contact>(`/contacts/${contact.id}`),
      staleTime: 60_000, // Use cached if less than 1 minute old
    });
  };

  return (
    <tr onMouseEnter={handleMouseEnter}>
      <td>
        <Link href={`/dashboard/contacts/${contact.id}`}>{contact.name}</Link>
      </td>
      <td>{contact.email}</td>
    </tr>
  );
}

Next.js App Router SSR + Consulta TanStack

// src/app/[locale]/dashboard/contacts/page.tsx (Server Component)
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
import { queryKeys } from '@/lib/query-keys';
import { getServerContacts } from '@/lib/server-api';
import { ContactsClient } from './contacts-client';

export default async function ContactsPage({ params }: Props) {
  const { locale } = await params;
  const queryClient = new QueryClient();

  // Prefetch on the server — data is embedded in the HTML
  await queryClient.prefetchQuery({
    queryKey: queryKeys.contacts.list({ page: 1, limit: 20 }),
    queryFn: () => getServerContacts({ page: 1, limit: 20 }),
  });

  return (
    // HydrationBoundary serializes the server QueryClient state
    // into the HTML, which the client QueryClient then picks up
    <HydrationBoundary state={dehydrate(queryClient)}>
      <ContactsClient />
    </HydrationBoundary>
  );
}
// src/app/[locale]/dashboard/contacts/contacts-client.tsx ('use client')
'use client';

import { useContacts } from '@/hooks/use-contacts';

export function ContactsClient() {
  const { data } = useContacts({ page: 1, limit: 20 });
  // data is immediately available from the server-dehydrated state
  // No loading state on first render — SSR data is used instantly

  return <ContactsTable data={data?.data ?? []} />;
}

Perguntas frequentes

Quando devo usar TanStack Query vs SWR vs uma busca simples?

Use o TanStack Query quando precisar: invalidação de cache após mutações, atualizações otimistas, rolagem infinita, pré-busca, nova busca em segundo plano ou desduplicação de solicitação. Use SWR se precisar de um pacote menor e apenas de casos de uso simples. Use fetch simples nos componentes do servidor Next.js onde você deseja o cache de dados nativo do React (a função cache() e o padrão revalidatePath()). O TanStack Query é para interatividade do lado do cliente, onde as ações do usuário afetam os dados do servidor.

Qual ​​é a diferença entre staleTime e gcTime?

staleTime é por quanto tempo os dados são considerados atualizados — durante esse período, nenhuma nova busca em segundo plano acontece e os dados armazenados em cache são retornados imediatamente. Depois que staleTime expirar, os dados ficarão "obsoletos" e serão recuperados em segundo plano na próxima utilização. gcTime (anteriormente cacheTime) é quanto tempo os dados não utilizados (sem assinantes ativos) permanecem na memória antes da coleta de lixo. Defina staleTime com base na rapidez com que seus dados mudam; defina gcTime com base em quanto tempo você deseja navegar e voltar para parecer instantâneo.

Como lidar com erros de autenticação (401) globalmente?

Adicione um manipulador de erros global nas opções padrão de QueryClient. Em 401, redirecione para login e limpe o cache de consultas. No TanStack Query v5: defaultOptions: { queries: { throwOnError: (error) => { if (error.status === 401) { router.push('/auth/login'); queryClient.clear(); return false; } return true; } } }. Alternativamente, lide com 401 em seu auxiliar apiFetch chamando uma função logout().

Como compartilho o estado da consulta entre componentes irmãos sem perfuração de suporte?

Qualquer componente que chame useQuery com a mesma chave de consulta compartilha os mesmos dados armazenados em cache – o TanStack Query desduplica as solicitações automaticamente. Dois componentes ContactsTable e um emblema ContactCount chamando useContacts({ page: 1, limit: 20 }) com os mesmos filtros serão todos lidos a partir de uma única solicitação em cache. Sem perfuração de suporte, sem necessidade de contexto.

As chaves de consulta devem incluir a sessão do usuário ou o ID da organização?

Sim — se o escopo dos dados for um usuário ou organização, inclua o identificador de escopo na chave de consulta. Isso evita que os dados de um usuário apareçam para outro (crítico para aplicativos multilocatários). Limpe também todo o cache de consulta ao sair: queryClient.clear() ou queryClient.removeQueries() — caso contrário, os dados obsoletos da sessão anterior poderão piscar brevemente para o próximo usuário.


Próximas etapas

O TanStack Query transforma a busca de dados React de um problema de gerenciamento de estado em um sistema declarativo com reconhecimento de cache. Atualizações otimistas, pré-busca e hidratação SSR proporcionam aos usuários uma experiência instantânea, mesmo em conexões lentas.

ECOSIRE constrói painéis de administração Next.js e portais de clientes com TanStack Query para todos os estados do servidor, combinado com Zustand para estado de UI somente cliente e TanStack Table para visualizações com muitos dados. Explore nossos serviços de engenharia de front-end para saber como criamos aplicativos React de alto desempenho e prontos para produção.

E

Escrito por

ECOSIRE Research and Development Team

Construindo produtos digitais de nível empresarial na ECOSIRE. Compartilhando insights sobre integrações Odoo, automação de e-commerce e soluções de negócios com IA.

Converse no WhatsApp