Boas Práticas de React Query com TypeScript: Queries, Mutations e Tipos Inferidos para Times Ágeis Já leu

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 em

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.

Referências


Artigos relacionados