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 + useEffectpara buscar dados em 2026, está trabalhando em modo hard. Dê uma chance ao React Query — você não vai querer voltar.