Consumindo APIs na Prática
Junte fetch, useState e useEffect em um padrão sólido, entenda métodos HTTP, status codes e quando trocar o fetch nativo pelo axios.
Você já sabe construir componentes reativos com React: guarda estado com useState, reage a mudanças com useEffect, organiza lógica em hooks personalizados e compartilha dados entre componentes com Context. Mas até agora, boa parte dos dados que alimentaram seus componentes veio de lugares controlados por você — um array fixo no código, um useState inicializado com valores de exemplo, talvez um fetch isolado que você testou no módulo de JavaScript no Navegador.
Chegou a hora de conectar essas interfaces a informação real: dados hospedados em um servidor que você não controla, mantido por outra equipe (ou outra empresa), escrito possivelmente em outra linguagem, rodando em algum lugar do mundo que você nunca vai visitar. Isso é o que fazemos quando consumimos uma API, e é a habilidade que separa quem sabe montar telas de quem sabe construir produtos de verdade.
O que é uma API, do ponto de vista de quem consome
API significa Application Programming Interface — interface de programação de aplicações. É um termo genérico, mas no contexto de desenvolvimento web, quando alguém diz "consumir uma API", quase sempre está falando de se comunicar com um serviço através da internet, usando HTTP.
Pense em uma API como um contrato: um conjunto de regras que diz exatamente como o seu front-end pode pedir informações (ou enviar informações) para um servidor, sem precisar saber nada sobre como esse servidor funciona por dentro. Você não sabe (nem precisa saber) se o back-end está escrito em Node.js, Python, Java ou Rust, se os dados estão em um banco PostgreSQL ou MongoDB, ou como o código interno está organizado. Você só precisa saber:
- Qual URL chamar (o endpoint)
- Que tipo de requisição fazer (o método HTTP)
- Que dados enviar, se for o caso
- Que formato de resposta esperar
- O que pode dar errado, e como isso é sinalizado
Se isso soa familiar, é porque é exatamente o mesmo princípio do encapsulamento que você estudou em POO no Starter: uma classe expõe métodos públicos e esconde os detalhes internos de implementação. Uma API faz a mesma coisa, só que entre dois sistemas diferentes, muitas vezes escritos por equipes diferentes, rodando em servidores diferentes.
O contrato entre front-end e back-end
Front-end e back-end normalmente são construídos, testados e publicados de forma independente. Times diferentes (ou até empresas diferentes, no caso de APIs de terceiros) trabalham em cada lado, com seus próprios cronogramas. O que permite que essas duas partes evoluam sem quebrar uma a outra é justamente o contrato da API se manter estável — ou, quando muda, mudar de forma comunicada e versionada.
Isso tem uma consequência prática muito importante para você, como desenvolvedor(a) front-end: seu código depende de uma promessa que você não controla. Se um campo do JSON de resposta for renomeado, se um endpoint mudar de método, ou se o servidor simplesmente ficar fora do ar por alguns minutos, seu componente React precisa continuar se comportando de forma previsível — sem quebrar a tela inteira, sem travar em "carregando" para sempre, e idealmente sem deixar o usuário sem entender o que aconteceu.
É por isso que este capítulo não é só "como chamar fetch". Grande parte dele é sobre tratar bem o que pode dar errado e sobre proteger dados e usuários quando seu código passa a depender de sistemas externos.
Tipos comuns de API
Existem diferentes estilos (ou paradigmas) de API, mas você vai encontrar um deles com muito mais frequência que os outros no dia a dia de front-end:
- REST (Representational State Transfer) é, disparadamente, o estilo mais comum. Cada informação do sistema é tratada como um recurso, identificado por uma URL (
/gastos,/gastos/12,/usuarios), e você usa métodos HTTP (GET,POST,PUT,DELETE...) para dizer o que quer fazer com aquele recurso. Os dados trafegam quase sempre em JSON. É o padrão que praticamente toda vaga júnior de front-end vai esperar que você domine, e é o foco deste capítulo. - GraphQL é uma alternativa em que existe um único endpoint, e o cliente (seu front-end) descreve exatamente quais campos quer receber em cada consulta, evitando pedir dados demais ou de menos. É poderoso, mas menos comum que REST na maioria das vagas júnior — vale saber que existe, mas não vamos nos aprofundar aqui.
- WebSockets servem para comunicação em tempo real e bidirecional (o servidor pode enviar dados sem o cliente pedir), muito usado em chats, notificações ao vivo e dashboards que atualizam sozinhos.
- gRPC é usado principalmente para comunicação entre serviços de back-end, raramente diretamente do navegador.
Tudo o que você vai praticar a seguir — métodos, status codes, autenticação — se aplica quase sem alteração a qualquer API REST que você encontrar no mercado, seja ela pública ou construída por um time interno.
Por que isso importa tanto para o front-end
Praticamente todo produto digital real depende de dados que vêm de fora: contas de usuário, catálogos de produtos, cotações, previsão do tempo, histórico de pedidos. Sem consumir APIs, você fica limitado a interfaces estáticas — bonitas, talvez, mas sem nenhuma conexão com o mundo real.
Este capítulo é a ponte entre "eu sei construir telas" e "eu construo produtos que refletem dados reais". E não por acaso: é exatamente o que falta para você evoluir o Rastreador de Gastos — que já foi um CLI em TypeScript no Starter — até o dashboard de finanças pessoais que vai fechar este curso, consumindo uma API pública de verdade e publicado com uma URL real.
De fetch isolado a um componente completo
Um componente que depende de dados externos passa, tipicamente, por três estados possíveis: está carregando os dados, carregou com sucesso, ou falhou ao carregar. Modelar esses três estados com clareza é a diferença entre uma tela que "trava" silenciosamente e uma tela que se comunica bem com quem está usando.
Vamos recuperar o exemplo da cotação do dólar que você já viu no Starter, mas agora dentro de um componente React:
import { useEffect, useState } from "react";
interface Cotacao {
bid: string;
}
function CotacaoDolar() {
const [cotacao, setCotacao] = useState<Cotacao | null>(null);
const [carregando, setCarregando] = useState(true);
const [erro, setErro] = useState<string | null>(null);
useEffect(() => {
async function buscarCotacao() {
try {
const resposta = await fetch(
"https://economia.awesomeapi.com.br/json/last/USD-BRL"
);
if (!resposta.ok) {
throw new Error(`Erro HTTP: ${resposta.status}`);
}
const dados = await resposta.json();
setCotacao(dados.USDBRL);
} catch (erro) {
if (erro instanceof Error) {
setErro(erro.message);
}
} finally {
setCarregando(false);
}
}
buscarCotacao();
}, []);
if (carregando) return <p>Carregando cotação...</p>;
if (erro) return <p>Não foi possível carregar a cotação: {erro}</p>;
return <p>Dólar agora: R$ {cotacao?.bid}</p>;
}Repare na estrutura: o array de dependências vazio ([]) faz a busca acontecer uma vez, quando o componente monta — o mesmo comportamento de useEffect que você já conhece. O try/catch/finally garante que, aconteça o que acontecer (sucesso ou erro), carregando sempre volta para false no finally, então a tela nunca fica presa em "Carregando..." para sempre.
Esse é o esqueleto que você vai repetir, com variações, toda vez que um componente React precisar de dados de uma API.
Os métodos HTTP e quando usar cada um
Cada requisição HTTP declara uma intenção através do seu método. Imagine que existe uma API de gastos pessoais, com um endpoint /gastos — os métodos mais comuns seriam usados assim:
| Método | Intenção | Exemplo |
|---|---|---|
GET | Buscar dados, sem alterar nada no servidor | GET /gastos → lista todos os gastos |
POST | Criar um novo recurso | POST /gastos → cria um novo gasto |
PUT | Substituir um recurso inteiro por um novo | PUT /gastos/12 → sobrescreve o gasto 12 por completo |
PATCH | Atualizar parcialmente um recurso | PATCH /gastos/12 → altera só o campo valor, por exemplo |
DELETE | Remover um recurso | DELETE /gastos/12 → apaga o gasto 12 |
Na prática, PUT e PATCH são frequentemente confundidos. A diferença importa: PUT espera o objeto completo (se você omitir um campo, ele pode ser apagado ou zerado, dependendo da implementação do servidor), enquanto PATCH espera só os campos que mudaram. Quando tiver dúvida sobre qual usar, PATCH costuma ser a escolha mais segura para atualizações parciais, como editar só a descrição de um gasto.
Fazer uma requisição POST com fetch exige um pouco mais de configuração que um GET, porque você precisa informar o método, os cabeçalhos e o corpo da requisição:
interface NovoGasto {
descricao: string;
valor: number;
categoria: string;
}
async function criarGasto(gasto: NovoGasto) {
const resposta = await fetch("https://api.mbacademy.dev/gastos", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(gasto),
});
if (!resposta.ok) {
throw new Error(`Não foi possível criar o gasto: ${resposta.status}`);
}
return resposta.json();
}Repare no cabeçalho Content-Type: application/json — ele avisa o servidor que o corpo da requisição (body) está em formato JSON. Sem esse cabeçalho, muitos servidores simplesmente não conseguem interpretar os dados que você enviou, mesmo que o JSON.stringify esteja correto.
Interpretando códigos de status HTTP
Toda resposta HTTP vem com um código de status — um número de três dígitos que resume o que aconteceu. Você já usou resposta.status e resposta.ok nos exemplos acima; agora vamos entender o que esses números realmente significam. Eles são agrupados em faixas:
| Faixa | Significado | Como reagir |
|---|---|---|
2xx | Sucesso | 200 OK (deu certo), 201 Created (recurso criado), 204 No Content (deu certo, sem corpo de resposta) — siga o fluxo normal |
3xx | Redirecionamento | O recurso mudou de lugar; o fetch geralmente já segue redirecionamentos sozinho |
4xx | Erro do cliente | 400 Bad Request (dados inválidos), 401 Unauthorized (não autenticado), 403 Forbidden (autenticado, mas sem permissão), 404 Not Found (recurso não existe) — o problema está na requisição que você enviou |
5xx | Erro do servidor | 500 Internal Server Error, 503 Service Unavailable — o problema é do lado do servidor, não há nada de errado com o que você enviou |
fetch não lança um erro automaticamente para respostas 4xx ou 5xx. Ele só rejeita a Promise quando a requisição falha de verdade (sem internet, DNS não resolveu, servidor inacessível). Por isso, todo exemplo deste capítulo verifica resposta.ok manualmente antes de continuar — esquecer essa verificação é um dos erros mais comuns de quem está aprendendo a consumir APIs, porque o código "parece" funcionar até o dia em que o servidor retorna um 404 e sua tela quebra silenciosamente.
Como as faixas têm significados diferentes, muitas vezes vale a pena reagir de forma diferente para cada uma, em vez de tratar todo erro da mesma maneira:
async function buscarGasto(id: number) {
const resposta = await fetch(`https://api.mbacademy.dev/gastos/${id}`);
if (resposta.status === 404) {
throw new Error("Gasto não encontrado.");
}
if (resposta.status === 401) {
throw new Error("Você precisa entrar na sua conta para ver isso.");
}
if (!resposta.ok) {
throw new Error(`Algo deu errado no servidor (${resposta.status}).`);
}
return resposta.json();
}Essa mensagem diferenciada é o que permite que sua interface mostre "Esse gasto não existe mais" em vez de um genérico "Ocorreu um erro" — uma diferença pequena no código, mas grande na experiência de quem usa o produto.
axios: uma alternativa ao fetch nativo
fetch é nativo do navegador e resolve bem a maioria dos casos, mas o mercado usa muito uma biblioteca chamada axios, que resolve alguns incômodos comuns:
- JSON automático: você acessa
resposta.datadiretamente, sem chamar.json()manualmente. - Erros em
4xx/5xxjá lançam exceção: diferente dofetch, o axios rejeita a Promise sozinho quando o status indica erro, então você não precisa checarresposta.okna mão. - Interceptors: funções que rodam automaticamente antes de toda requisição (por exemplo, para anexar um token de autenticação) ou depois de toda resposta (por exemplo, para tratar erros de forma centralizada).
- Cancelamento de requisições com a mesma API
AbortControllerdofetchnativo.
Primeiro, instale a biblioteca:
npm install axiosCompare os dois, buscando a lista de gastos:
Com fetch:
async function buscarGastos() {
const resposta = await fetch("https://api.mbacademy.dev/gastos");
if (!resposta.ok) {
throw new Error(`Erro HTTP: ${resposta.status}`);
}
return resposta.json();
}Com axios:
import axios from "axios";
async function buscarGastos() {
const resposta = await axios.get("https://api.mbacademy.dev/gastos");
return resposta.data;
// Se o servidor responder 4xx ou 5xx, o axios já lança
// uma exceção sozinho — não é preciso checar o status manualmente.
}Um dos usos mais práticos do axios é criar uma instância configurada uma única vez, com uma URL base e interceptors, para reaproveitar em todo o projeto:
// src/api/client.ts
import axios from "axios";
export const api = axios.create({
baseURL: "https://api.mbacademy.dev",
});
// Roda antes de toda requisição feita com "api"
api.interceptors.request.use((config) => {
console.log(`Chamando: ${config.method?.toUpperCase()} ${config.url}`);
return config;
});
// Roda depois de toda resposta (ou erro) recebida com "api"
api.interceptors.response.use(
(resposta) => resposta,
(erro) => {
console.error("Requisição falhou:", erro.message);
return Promise.reject(erro);
}
);Com isso, qualquer chamada feita com api.get(...), api.post(...) etc. já passa automaticamente pelos dois interceptors, sem repetir essa lógica em cada componente. No próximo capítulo, vamos usar exatamente esse padrão de interceptor para anexar um token de autenticação em toda requisição.
Não existe "certo" ou "errado" entre fetch e axios — fetch é suficiente para a maioria dos projetos pequenos e não exige instalar nada, enquanto axios compensa em projetos maiores, com muitas chamadas de API e necessidade de lógica compartilhada (como autenticação). Muitas vagas júnior vão pedir familiaridade com os dois, então vale a pena entender ambos, mesmo que você tenha uma preferência pessoal.
Um padrão reutilizável para o estado da requisição
Repare que, no primeiro exemplo deste capítulo, usamos três variáveis de estado separadas (cotacao, carregando, erro). Isso funciona, mas permite combinações que não deveriam existir — por exemplo, nada impede carregando e erro de estarem true ao mesmo tempo, o que não faz sentido de verdade.
Uma forma mais robusta de modelar isso é com um único estado, representado por um tipo que só permite combinações válidas:
type EstadoRequisicao<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; dados: T }
| { status: "error"; mensagem: string };Esse tipo diz: em qualquer momento, a requisição está em exatamente um desses quatro estados — nunca dois ao mesmo tempo, e nunca nenhum. Podemos encapsular esse padrão em um hook personalizado, reaproveitável em qualquer componente que precise buscar dados:
import { useEffect, useState } from "react";
type EstadoRequisicao<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; dados: T }
| { status: "error"; mensagem: string };
function useApi<T>(url: string): EstadoRequisicao<T> {
const [estado, setEstado] = useState<EstadoRequisicao<T>>({ status: "idle" });
useEffect(() => {
const controller = new AbortController();
async function buscar() {
setEstado({ status: "loading" });
try {
const resposta = await fetch(url, { signal: controller.signal });
if (!resposta.ok) {
throw new Error(`Erro HTTP: ${resposta.status}`);
}
const dados: T = await resposta.json();
setEstado({ status: "success", dados });
} catch (erro) {
if (erro instanceof Error && erro.name !== "AbortError") {
setEstado({ status: "error", mensagem: erro.message });
}
}
}
buscar();
// Cancela a requisição se o componente desmontar ou a URL mudar
// antes da resposta chegar, evitando atualizar estado de um
// componente que já não existe mais na tela.
return () => controller.abort();
}, [url]);
return estado;
}E usá-lo em um componente fica bem mais declarativo, com um switch que cobre todos os casos possíveis:
interface Gasto {
id: number;
descricao: string;
valor: number;
}
function ListaDeGastos() {
const estado = useApi<Gasto[]>("https://api.mbacademy.dev/gastos");
switch (estado.status) {
case "idle":
case "loading":
return <p>Carregando gastos...</p>;
case "error":
return <p>Erro ao carregar: {estado.mensagem}</p>;
case "success":
return (
<ul>
{estado.dados.map((gasto) => (
<li key={gasto.id}>
{gasto.descricao} — R$ {gasto.valor.toFixed(2)}
</li>
))}
</ul>
);
}
}Note que, dentro de case "success", o TypeScript já sabe que estado.dados existe e tem o tipo Gasto[] — e dentro de case "error", ele sabe que estado.mensagem existe. Essa é a vantagem de modelar estado como uma união de casos: o próprio compilador te impede de acessar um campo que não existe naquele estado específico.
Ao adotar esse padrão, resista à tentação de "economizar" e usar só um booleano carregando junto de um campo dados que pode ser null. Combinações impossíveis (como carregando: false, dados: null, erro: null, todos "vazios" sem explicação) são exatamente o tipo de bug sutil que aparece em produção e é difícil de reproduzir. Modelar o estado como uma união de casos elimina essa classe inteira de erro.
Para praticar
Cenário: Você está construindo a tela de resumo do dashboard de finanças pessoais. Ela precisa buscar a lista de gastos de uma API e exibir um retrato fiel do que está acontecendo: carregando, sucesso ou erro.
Requisitos:
- Crie uma interface
Gastocom os camposid: number,descricao: string,valor: numberecategoria: string. - Implemente (ou reaproveite) um hook
useApi<T>(url: string)que retorne um valor do tipoEstadoRequisicao<T>, com os quatro casosidle,loading,successeerror, como visto neste capítulo. - Crie um componente
ResumoDeGastosque useuseApi<Gasto[]>para buscar os dados de"https://api.mbacademy.dev/gastos". - No estado
"loading", exiba a mensagem"Carregando gastos...". - No estado
"error", exiba a mensagem de erro recebida, precedida de"Erro: ". - No estado
"success", exiba o total gasto (soma de todos os valores, usandoreduce) e a quantidade de gastos encontrados.
Clique para ver uma possível solução
import { useEffect, useState } from "react";
interface Gasto {
id: number;
descricao: string;
valor: number;
categoria: string;
}
type EstadoRequisicao<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; dados: T }
| { status: "error"; mensagem: string };
function useApi<T>(url: string): EstadoRequisicao<T> {
const [estado, setEstado] = useState<EstadoRequisicao<T>>({ status: "idle" });
useEffect(() => {
const controller = new AbortController();
async function buscar() {
setEstado({ status: "loading" });
try {
const resposta = await fetch(url, { signal: controller.signal });
if (!resposta.ok) {
throw new Error(`Erro HTTP: ${resposta.status}`);
}
const dados: T = await resposta.json();
setEstado({ status: "success", dados });
} catch (erro) {
if (erro instanceof Error && erro.name !== "AbortError") {
setEstado({ status: "error", mensagem: erro.message });
}
}
}
buscar();
return () => controller.abort();
}, [url]);
return estado;
}
function ResumoDeGastos() {
const estado = useApi<Gasto[]>("https://api.mbacademy.dev/gastos");
switch (estado.status) {
case "idle":
case "loading":
return <p>Carregando gastos...</p>;
case "error":
return <p>Erro: {estado.mensagem}</p>;
case "success": {
const total = estado.dados.reduce((acc, gasto) => acc + gasto.valor, 0);
return (
<div>
<p>Total gasto: R$ {total.toFixed(2)}</p>
<p>Quantidade de gastos: {estado.dados.length}</p>
</div>
);
}
}
}Com isso, você já sabe buscar dados, tratar erros por faixa de status, escolher entre fetch e axios, e modelar o estado de uma requisição de forma robusta. Mas em algum momento — geralmente sem aviso prévio — você vai tentar consumir uma API de outro domínio e ver o console do navegador cheio de mensagens vermelhas sobre CORS. É exatamente esse mistério que vamos desvendar a seguir.
Context e Gerenciamento de Estado
Evite o prop drilling com a Context API e conheça, em nível conceitual, quando bibliotecas como Redux e Zustand fazem sentido.
CORS e Segurança no Front-end
Entenda o que é CORS, como interpretar seus erros no console, e as vulnerabilidades mais comuns que um front-end pode introduzir sem perceber.