Por que React Query Revolucionou o Gerenciamento de Estado
Durante minha carreira, observei que a maior dificuldade dos desenvolvedores não está em aprender React, mas em gerenciar dados do servidor de forma eficiente. Antes do React Query, a maioria dos projetos acabava em um caos de estados locais, requisições duplicadas e sincronização manual de cache. O React Query (agora TanStack Query) resolveu isso de forma elegante, abstraindo toda a complexidade do cache, sincronização e refetch de dados.
Quando você integra TypeScript com React Query, ganha uma camada adicional de segurança: tipos inferidos automaticamente, autocomplete no IDE e erros detectados em tempo de compilação. Isso não é apenas conveniente—é a diferença entre um aplicativo robusto e um cheio de bugs sutis que aparecem em produção.
Configuração Inicial e Entendimento de Queries
O que é uma Query?
Uma Query no React Query é uma forma declarativa de buscar dados do servidor e mantê-los sincronizados com seu aplicativo. Diferentemente de um simples fetch em useEffect, uma Query cuida automaticamente de caching, refetch em background, garbage collection e muito mais. Você descreve onde buscar os dados e quando usá-los; o React Query gerencia o resto.
import { useQuery } from '@tanstack/react-query';
import axios from 'axios';
interface User {
id: number;
name: string;
email: string;
}
// Função que faz a requisição
const fetchUser = async (userId: number): Promise<User> => {
const response = await axios.get(`/api/users/${userId}`);
return response.data;
};
// Componente usando a Query
function UserProfile({ userId }: { userId: number }) {
const { data, isLoading, error } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
if (isLoading) return <p>Carregando...</p>;
if (error) return <p>Erro: {error.message}</p>;
return <div>{data?.name} ({data?.email})</div>;
}
Aqui, o React Query infere automaticamente que data tem o tipo User | undefined. O queryKey funciona como um identificador único e também como dependência—se userId muda, a Query refaz automaticamente.
Configurando o QueryClient
Toda aplicação React Query precisa de um QueryClient que centraliza a configuração global. Isso deve ser feito uma única vez, geralmente no arquivo raiz da aplicação.
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 minutos
gcTime: 10 * 60 * 1000, // 10 minutos (antes: cacheTime)
retry: 1,
refetchOnWindowFocus: true,
},
},
});
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<YourAppComponent />
</QueryClientProvider>
);
}
O staleTime define quanto tempo os dados são considerados "frescos" sem precisar refetch. O gcTime (antigo cacheTime) define quando dados não usados são descartados. Esses valores devem ser ajustados conforme seu caso de uso.
Mutations: Alterando Dados no Servidor
Entendendo Mutations com Tipos Seguros
Se Queries são para ler dados, Mutations são para escrever. Uma Mutation é uma operação que modifica dados no servidor—criar, atualizar ou deletar. Com TypeScript, você controla tanto os dados enviados quanto a resposta esperada.
import { useMutation, useQueryClient } from '@tanstack/react-query';
interface CreateUserInput {
name: string;
email: string;
}
interface CreateUserResponse {
id: number;
name: string;
email: string;
createdAt: string;
}
const createUser = async (input: CreateUserInput): Promise<CreateUserResponse> => {
const response = await axios.post('/api/users', input);
return response.data;
};
function CreateUserForm() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: createUser,
onSuccess: (newUser) => {
// TypeScript sabe que newUser é CreateUserResponse
console.log('Usuário criado:', newUser.id, newUser.email);
// Invalida a Query de usuários para refetch automático
queryClient.invalidateQueries({ queryKey: ['users'] });
},
onError: (error: Error) => {
console.error('Erro ao criar:', error.message);
},
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
mutation.mutate({
name: formData.get('name') as string,
email: formData.get('email') as string,
});
};
return (
<form onSubmit={handleSubmit}>
<input name="name" placeholder="Nome" required />
<input name="email" type="email" placeholder="Email" required />
<button disabled={mutation.isPending}>
{mutation.isPending ? 'Criando...' : 'Criar Usuário'}
</button>
{mutation.isError && <p>Erro: {mutation.error.message}</p>}
</form>
);
}
Observe como mutation.mutate() recebe um objeto tipado. Se você tentar passar um campo incorreto, TypeScript reclama antes do código executar. Além disso, no callback onSuccess, você sabe exatamente qual é o tipo de newUser.
Atualizações Otimistas
Uma das características mais poderosas do React Query é a atualização otimista: você atualiza a UI imediatamente, e se algo der errado, reverte para o estado anterior. Isso cria uma experiência de usuário fluida.
interface UpdateUserInput {
id: number;
name?: string;
email?: string;
}
const updateUser = async (input: UpdateUserInput): Promise<User> => {
const response = await axios.patch(`/api/users/${input.id}`, input);
return response.data;
};
function EditUserForm({ user }: { user: User }) {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: updateUser,
onMutate: async (newData) => {
// Cancela refetch em background para evitar conflito
await queryClient.cancelQueries({ queryKey: ['user', user.id] });
// Salva o estado anterior
const previousUser = queryClient.getQueryData<User>(['user', user.id]);
// Atualiza cache otimisticamente
queryClient.setQueryData(['user', user.id], {
...user,
...newData,
});
return { previousUser };
},
onError: (error, variables, context) => {
// Se houver erro, reverte para o anterior
if (context?.previousUser) {
queryClient.setQueryData(['user', user.id], context.previousUser);
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['user', user.id] });
},
});
return (
<button
onClick={() => mutation.mutate({ id: user.id, name: 'Novo Nome' })}
disabled={mutation.isPending}
>
Atualizar
</button>
);
}
O fluxo aqui é: 1) onMutate atualiza a UI imediatamente, 2) a requisição é enviada, 3) se suceder, valida os dados; se falhar, reverte com onError.
Tipos Inferidos e Padrões Avançados
Inferência Automática com TypeScript
Uma das maravilhas de usar React Query com TypeScript é que você pode deixar o TypeScript inferir tipos de funções simples, reduzindo boilerplate. A chave é estruturar suas funções de forma clara.
// Essa função retorna User[], TypeScript infere automaticamente
async function fetchUsers() {
const response = await axios.get<User[]>('/api/users');
return response.data;
}
// Aqui data será tipado como User[] sem precisar declarar manualmente
const { data: users } = useQuery({
queryKey: ['users'],
queryFn: fetchUsers, // Tipo inferido
});
// Se você quiser ser explícito (recomendado em funções complexas):
const { data: usersExplicit } = useQuery<User[]>({
queryKey: ['users'],
queryFn: fetchUsers,
});
Em projetos reais, é comum criar um padrão wrapper para todas as requisições. Assim centralizamos a lógica de erro e autenticação:
// api.ts - seu cliente HTTP centralizado
import axios, { AxiosError } from 'axios';
interface ApiError {
code: string;
message: string;
}
const apiClient = axios.create({
baseURL: process.env.REACT_APP_API_URL,
headers: {
'Content-Type': 'application/json',
},
});
apiClient.interceptors.response.use(
response => response,
error => {
const apiError: ApiError = {
code: error.response?.data?.code || 'UNKNOWN_ERROR',
message: error.response?.data?.message || error.message,
};
return Promise.reject(apiError);
}
);
export const api = {
getUser: (id: number) => apiClient.get<User>(`/users/${id}`).then(r => r.data),
getUsers: () => apiClient.get<User[]>('/users').then(r => r.data),
createUser: (data: CreateUserInput) =>
apiClient.post<CreateUserResponse>('/users', data).then(r => r.data),
updateUser: (id: number, data: Partial<User>) =>
apiClient.patch<User>(`/users/${id}`, data).then(r => r.data),
};
// Componente usando
function UsersList() {
const { data: users } = useQuery({
queryKey: ['users'],
queryFn: api.getUsers,
});
return <ul>{users?.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}
Paginação e Queries Dinâmicas
Paginar com React Query é trivial quando você estrutura bem. A chave é incluir a página no queryKey.
interface PaginatedResponse<T> {
data: T[];
total: number;
page: number;
limit: number;
}
function UsersList() {
const [page, setPage] = useState(1);
const { data, isLoading } = useQuery({
queryKey: ['users', page], // page é parte da chave!
queryFn: () =>
api.getUsers({ page, limit: 10 })
.then(r => r as PaginatedResponse<User>),
});
return (
<>
{data?.data.map(u => <div key={u.id}>{u.name}</div>)}
<button onClick={() => setPage(p => p + 1)} disabled={isLoading}>
Próxima
</button>
</>
);
}
Quando page muda, React Query automaticamente faz uma nova requisição. O cache anterior permanece, então se o usuário voltar para a página 1, os dados já estão prontos.
Usando useQueries para Múltiplas Queries
Às vezes você precisa buscar vários recursos. O hook useQueries permite isso de forma eficiente:
function UserDashboard({ userIds }: { userIds: number[] }) {
const queries = useQueries({
queries: userIds.map(id => ({
queryKey: ['user', id],
queryFn: () => api.getUser(id),
})),
});
// queries é um array onde cada item é { data, isLoading, error }
const allLoading = queries.some(q => q.isLoading);
const users = queries.map(q => q.data).filter(Boolean);
if (allLoading) return <p>Carregando...</p>;
return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}
Isso é muito mais eficiente que múltiplos useQuery separados, pois o React Query otimiza as requisições.
Boas Práticas e Padrões de Produção
Tratamento de Erros com Tipos Customizados
Em uma aplicação real, seus erros de API seguem um padrão. TypeScript permite tipá-los corretamente:
interface ApiErrorResponse {
error: {
code: string;
message: string;
details?: Record<string, string>;
};
}
async function fetchUserSafe(id: number): Promise<User> {
try {
const response = await apiClient.get<User>(`/users/${id}`);
return response.data;
} catch (err) {
const error = err as AxiosError<ApiErrorResponse>;
throw new Error(error.response?.data?.error?.message || 'Erro desconhecido');
}
}
function UserProfile({ userId }: { userId: number }) {
const { data, error } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUserSafe(userId),
});
if (error instanceof Error) {
return <p>Erro: {error.message}</p>;
}
return <div>{data?.name}</div>;
}
DevTools para Debug
React Query fornece DevTools que facilitam muito o debug. Instale e use assim:
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { QueryClientProvider, QueryClient } from '@tanstack/react-query';
const queryClient = new QueryClient();
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<YourApp />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
Os DevTools mostram todas as Queries ativas, seu estado, histórico de requisições e permitem refetch manual. Indispensável para desenvolvimento.
Conclusão
Dominando React Query com TypeScript, você alcança três marcos importantes: 1) Segurança de tipos desde a busca até a exibição de dados, eliminando a maioria dos erros silenciosos que ocorrem em runtime; 2) Código mais limpo e declarativo, onde você descreve o que precisa (uma Query) e o framework cuida de como buscar, cachear e sincronizar; 3) Experiências de usuário superiores com atualizações otimistas, paginação automática e refetch inteligente, tudo com pouco código.
O React Query não é apenas uma biblioteca—é uma mudança de paradigma em como pensamos sobre estado remoto. Quando você internaliza seus conceitos, nunca mais volta a gerenciar requisições manualmente.