Autenticação e Interceptors com Axios no React
Como estruturar um cliente HTTP robusto com Axios: instância configurada, injeção automática de token, interceptor de resposta com retry, cancelamento de requisições e integração com React Query.
Todo projeto React que consome uma API acaba com o mesmo problema: código de autenticação espalhado por dezenas de componentes. Cada useEffect com seu próprio Authorization header, cada catch com sua própria lógica de 401, cada componente replicando a URL base da API. O resultado é um código frágil e difícil de manter.
A solução é centralizar toda a lógica HTTP em uma instância única do Axios com Interceptors. Interceptors são middlewares do cliente HTTP — eles processam todas as requisições antes de enviá-las e todas as respostas antes de entregá-las ao componente, sem que o componente precise saber que isso acontece.
Por que Axios ao invés de fetch?
O fetch nativo é excelente para casos simples, mas o Axios tem vantagens estruturais para projetos maiores:
- Interceptors — pipeline de transformação de request/response, impossível de replicar elegantemente com fetch
- Cancelamento nativo — via
AbortController, já integrado na API do Axios - Timeout por requisição —
fetchnão tem timeout nativo - Transformação automática de JSON — sem
response.json()manual - Tipagem superior —
AxiosResponse<T>eAxiosError<T>são tipados de forma mais granular
Estruturando a Instância Central
Crie um único arquivo src/lib/api.ts que exporta a instância configurada. Toda chamada de API no projeto importa daqui — nunca do axios diretamente.
import axios, { type AxiosError, type InternalAxiosRequestConfig } from 'axios';
// Access Token em memória — nunca no localStorage (vulnerável a XSS)
let accessToken: string | null = null;
export function setAccessToken(token: string): void {
accessToken = token;
}
export function clearAccessToken(): void {
accessToken = null;
}
export const api = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3333',
timeout: 10_000, // 10 segundos de timeout global
headers: {
'Content-Type': 'application/json',
},
// Necessário para enviar/receber HttpOnly Cookies (Refresh Token)
withCredentials: true,
});Request Interceptor: Injetando o Token
O request interceptor roda antes de cada requisição sair. Injeta o Access Token em memória no header Authorization:
api.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`;
}
return config;
},
(error) => Promise.reject(error)
);Response Interceptor: Renovação de Token com Fila
O response interceptor é a parte mais complexa. Ele precisa lidar com 401, renovar o token e repetir a requisição original — tudo de forma transparente. O desafio é evitar múltiplas chamadas de refresh simultâneas quando várias requisições falham ao mesmo tempo:
type FailedRequest = {
resolve: (token: string) => void;
reject: (err: AxiosError) => void;
};
let isRefreshing = false;
let failedRequestsQueue: Array<FailedRequest> = [];
api.interceptors.response.use(
// Resposta bem-sucedida: passa direto
(response) => response,
// Resposta com erro
async (error: AxiosError<{ code?: string }>) => {
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
// Só trata 401 e evita loop infinito
if (error.response?.status !== 401 || originalRequest._retry) {
return Promise.reject(error);
}
// Verifica se é token expirado (o backend deve retornar um código)
const isTokenExpired = error.response.data?.code === 'token.expired';
if (!isTokenExpired) {
// 401 por outra razão (ex: sem permissão) — não tenta renovar
clearAccessToken();
window.location.href = '/login';
return Promise.reject(error);
}
if (isRefreshing) {
// Já está renovando: coloca na fila e aguarda
return new Promise<string>((resolve, reject) => {
failedRequestsQueue.push({ resolve, reject });
})
.then((newToken) => {
originalRequest.headers.Authorization = `Bearer ${newToken}`;
return api(originalRequest);
})
.catch((err) => Promise.reject(err));
}
originalRequest._retry = true;
isRefreshing = true;
try {
// O Refresh Token é enviado automaticamente via HttpOnly Cookie
const { data } = await api.post<{ accessToken: string }>('/auth/refresh');
const newToken = data.accessToken;
setAccessToken(newToken);
// Resolve todas as requisições que estavam aguardando
failedRequestsQueue.forEach(({ resolve }) => resolve(newToken));
failedRequestsQueue = [];
// Repete a requisição original com o novo token
originalRequest.headers.Authorization = `Bearer ${newToken}`;
return api(originalRequest);
} catch (refreshError) {
// Refresh falhou: rejeita todas as requisições na fila e desloga
failedRequestsQueue.forEach(({ reject }) => reject(refreshError as AxiosError));
failedRequestsQueue = [];
clearAccessToken();
window.location.href = '/login';
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
);Cancelamento de Requisições com AbortController
Um problema comum em React é a requisição que retorna depois que o componente foi desmontado — o temido Warning: Can't perform a React state update on an unmounted component. A solução correta é cancelar a requisição no cleanup do useEffect:
import { useState, useEffect } from 'react';
import { api } from '../lib/api';
import type { CanceledError } from 'axios';
interface User {
id: string;
name: string;
email: string;
}
export function useUsers() {
const [users, setUsers] = useState<Array<User>>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const controller = new AbortController();
async function fetchUsers() {
try {
setIsLoading(true);
const { data } = await api.get<Array<User>>('/users', {
// Vincula o AbortController ao Axios
signal: controller.signal,
});
setUsers(data);
} catch (err) {
// Ignora erros de cancelamento — são intencionais
if ((err as CanceledError).code === 'ERR_CANCELED') return;
setError('Falha ao carregar usuários.');
} finally {
setIsLoading(false);
}
}
fetchUsers();
return () => {
// Cancela a requisição quando o componente desmonta
// ou quando o useEffect re-executa (dependências mudaram)
controller.abort();
};
}, []);
return { users, isLoading, error };
}Se você usa React Query ou SWR, eles gerenciam o cancelamento automaticamente. O cancelamento manual com AbortController é necessário apenas quando você usa useEffect diretamente para fazer fetching — o que geralmente é um sinal de que você deveria usar React Query.
Integração com React Query
A combinação mais poderosa é usar a instância do Axios dentro do React Query. O React Query cuida do cache, loading states, refetch e invalidação. O Axios cuida dos interceptors de autenticação:
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '../lib/api';
interface User {
id: string;
name: string;
email: string;
}
// Query Keys como constantes — evita strings mágicas espalhadas
export const userKeys = {
all: ['users'] as const,
detail: (id: string) => ['users', id] as const,
};
// Hook de leitura
export function useUsers() {
return useQuery({
queryKey: userKeys.all,
queryFn: async ({ signal }) => {
const { data } = await api.get<Array<User>>('/users', { signal });
return data;
},
staleTime: 5 * 60 * 1000, // 5 minutos antes de refetch
});
}
// Hook de mutação
export function useCreateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (newUser: Omit<User, 'id'>) => {
const { data } = await api.post<User>('/users', newUser);
return data;
},
onSuccess: () => {
// Invalida o cache da lista após criar um usuário
queryClient.invalidateQueries({ queryKey: userKeys.all });
},
});
}Tipando Erros do Axios
Um ponto que muitos desenvolvedores erram: o tipo do erro no catch do Axios é unknown, não AxiosError. Use o type guard fornecido pelo próprio Axios:
import { isAxiosError } from 'axios';
// Tipo do corpo de erro que a API retorna
interface ApiError {
message: string;
code?: string;
field?: string;
}
export function handleApiError(error: unknown): string {
if (isAxiosError<ApiError>(error)) {
// Erro com resposta do servidor (4xx, 5xx)
if (error.response) {
return error.response.data.message ?? 'Erro no servidor.';
}
// Sem resposta: timeout, rede, CORS
if (error.request) {
return 'Sem resposta do servidor. Verifique sua conexão.';
}
}
// Erro inesperado (bug no código)
return 'Erro inesperado. Tente novamente.';
}
// Uso:
// try {
// await api.post('/users', data);
// } catch (err) {
// toast.error(handleApiError(err));
// }Conclusão
Uma instância Axios bem configurada é a fundação de um frontend escalável. Com ela, seus componentes e hooks ficam livres de lógica de autenticação — eles simplesmente fazem chamadas de API e recebem dados tipados. Toda a complexidade de injeção de token, renovação transparente, cancelamento e tratamento de erro vive em um único lugar, testável de forma isolada.