Consumir API no React: fetch, carregando e erro
Como buscar dados de uma API dentro do componente, tratar carregando e erro, cancelar requisição antiga e lidar com respostas que chegam fora de ordem.
Você troca o filtro de “Básico” para “Avançado”, o título muda, mas a lista termina mostrando as turmas básicas. Nenhuma mensagem de erro aparece: a resposta antiga apenas chegou depois e sobrescreveu a escolha nova.
Nesta aula, você vai da primeira busca a um painel que representa espera, vazio, erro e sucesso, rejeita respostas HTTP defeituosas e evita corridas entre requisições. No fim, a lógica repetida vira um hook reutilizável.
Uma API é um contrato por onde programas trocam dados. O navegador com React é o cliente, que pede; o programa que atende é o servidor. A rede é o caminho entre eles, e HTTP é o conjunto de regras da conversa, incluindo o status, número como 200 ou 404 que resume o resultado.
fetch é a função do navegador que inicia a requisição HTTP e devolve uma
Promise, objeto que representará um resultado futuro. A resposta pode trazer
JSON, formato de texto para dados estruturados. useState guarda valores
entre renderizações, execuções do componente que descrevem a tela;
useEffect sincroniza o componente com algo externo, como a rede. Ambos são
hooks, funções do React cujo nome começa com use.
Pense numa comanda: componente é o cliente, comanda é a requisição, garçom é
a rede, cozinha é o servidor e prato é a resposta em JSON. O status informa se
o pedido foi atendido. AbortController avisa que o cliente não quer mais
esperar pela comanda antiga. O limite da analogia: abortar o fetch não desfaz
trabalho que o servidor já realizou nem apaga bytes já recebidos; ele interrompe
o que ainda pode ser interrompido no lado cliente.
Os exemplos usam a Escola Aurora, uma escola de idiomas fictícia criada para esta aula. A API local introduz atrasos diferentes para tornar visível a corrida que, numa aplicação real, pode acontecer rápido demais para notar.
// api.mjs — a API da Escola Aurora
import express from 'express';
const turmas = {
basico: [
{ id: 'B-101', nome: 'Inglês Básico — seg/qua 08h', turno: 'manha', vagas: 4 },
{ id: 'B-102', nome: 'Inglês Básico — ter/qui 19h', turno: 'noite', vagas: 0 },
],
intermediario: [
{ id: 'I-201', nome: 'Inglês Intermediário — seg/qua 20h', turno: 'noite', vagas: 2 },
],
avancado: [
{ id: 'A-301', nome: 'Conversação Avançada — sáb 09h', turno: 'manha', vagas: 6 },
{ id: 'A-302', nome: 'Conversação Avançada — qui 19h', turno: 'noite', vagas: 3 },
],
corporativo: [],
};
const atraso = { basico: 900, intermediario: 400, avancado: 150, corporativo: 200 };
const app = express();
app.get('/turmas', async (req, res) => {
const { nivel = '', turno } = req.query;
if (!turmas[nivel]) {
return res.status(404).json({ erro: `nivel "${nivel}" nao existe` });
}
await new Promise((r) => setTimeout(r, atraso[nivel]));
const lista = turno ? turmas[nivel].filter((t) => t.turno === turno) : turmas[nivel];
res.json(lista);
});
app.listen(4517, () => console.log('API da Escola Aurora em http://localhost:4517'));turmas é o conjunto de dados; atraso define quanto cada nível espera. Express
cria o servidor e a rota HTTP GET /turmas. Nela, req.query lê nivel e
turno da URL, o status 404 sinaliza nível inexistente, filter limita o turno
e res.json envia a lista como JSON. listen(4517) abre a porta local 4517.
No terminal, a janela de texto usada para executar comandos, a linha abaixo inicia esse servidor:
node api.mjsQuais estados uma tela de API precisa mostrar?
No mínimo: carregando, erro, vazio e sucesso. Eles distinguem uma requisição em andamento de uma resposta sem itens ou de uma falha verdadeira:
- carregando — o pedido saiu e ninguém respondeu ainda;
- erro — a resposta não veio, ou veio com defeito;
- vazio — a resposta veio certinha, e é uma lista sem nada dentro;
- sucesso — tem dado para desenhar.
Quem esquece um deles escreve a tela que pisca em branco, ou a tela que mostra
“nenhuma turma” enquanto a requisição ainda está no ar. Três useState e três
return antecipados resolvem:
Experimente você mesmo
A sequência esperada é carregando, lista do básico, carregando e vazio. Antes de ler a saída, preveja o texto e a quantidade de linhas de cada etapa. Execute o painel e compare com sua anotação.
const API = 'http://localhost:4517';
function PainelDeTurmas({ nivel }) {
const [turmas, setTurmas] = useState([]);
const [carregando, setCarregando] = useState(true);
const [erro, setErro] = useState(null);
useEffect(() => {
setCarregando(true);
setErro(null);
fetch(`${API}/turmas?nivel=${nivel}`)
.then((resposta) => resposta.json())
.then((dados) => setTurmas(dados))
.catch((e) => setErro(e.message))
.finally(() => setCarregando(false));
}, [nivel]);
if (carregando) return <p>Carregando turmas…</p>;
if (erro) return <p>Não deu para carregar as turmas: {erro}</p>;
if (turmas.length === 0) return <p>Nenhuma turma aberta neste nível.</p>;
return (
<ul>
{turmas.map((turma) => (
<li key={turma.id}>
{turma.nome} — {turma.vagas} vagas
</li>
))}
</ul>
);
}Os três useState guardam dados, loading (carregando) e erro. O efeito roda
ao montar e sempre que nivel muda; fetch inicia a busca, os .then leem o
JSON e salvam as turmas, .catch recebe uma Promise rejeitada e .finally
encerra a espera. Os return escolhem uma única tela antes de chegar à lista.
Esta é a versão mínima para enxergar os estados. Ela ainda não cancela a busca
anterior nem verifica resposta.ok: fetch normalmente não rejeita a Promise
só porque o servidor respondeu com status 4xx ou 5xx. Corrigiremos ambos.
Montei esse componente com nivel="basico", imprimi o HTML gerado logo depois
da montagem e depois da resposta, e então troquei para corporativo, que é um
nível que existe na API e não tem turma aberta:
Começar carregando em true evita mostrar o vazio antes da primeira resposta.
A ordem também importa: erro antes de vazio, vazio antes da lista. Revise esses
return em renderização condicional
e o key em listas com map e key.
Como usar async dentro do useEffect?
Mantenha a função do efeito síncrona e declare uma função async dentro dela.
Assim, o retorno do efeito continua reservado para a limpeza.
async marca uma função que devolve Promise; await espera o resultado dentro
dela sem travar a página. A tentação é colocar async direto no efeito:
useEffect(async () => {
const resposta = await fetch(`${API}/turmas?nivel=${nivel}`);
setTurmas(await resposta.json());
}, [nivel]);O código até busca os dados, mas o React reclama alto no console — e a reclamação é longa porque ela vem com a solução escrita:
It looks like you wrote useEffect(async () => …) or returned a Promise. Instead, write the async function inside your effect and call it immediately:
useEffect(() => { async function fetchData() { // You can await here const response = await MyAPI.getData(someId); // … } fetchData(); }, [someId]); // Or [] if effect doesn’t need props or state
Learn more about data fetching with Hooks: https://react.dev/link/hooks-data-fetching
O retorno do efeito é reservado para a função de limpeza, chamada antes de
repetir o efeito ou desmontar o componente. Como uma função async sempre
devolve Promise, ela não pode ocupar esse lugar.
A forma correta é declarar a função assíncrona dentro do efeito e chamá-la na linha seguinte:
useEffect(() => {
async function buscarTurmas() {
const resposta = await fetch(`${API}/turmas?nivel=${nivel}`);
setTurmas(await resposta.json());
}
buscarTurmas();
}, [nivel]);buscarTurmas pode usar await porque ela é assíncrona. O efeito apenas chama
essa função e continua sem devolver sua Promise; [nivel] manda repetir a busca
quando a prop mudar.
Se async e await ainda soam nebulosos, a lição de
async e await em JavaScript mostra a mecânica
por trás, e a de Fetch API cobre o fetch fora
do React.
Como cancelar uma requisição antiga com AbortController?
Crie um AbortController, passe seu signal ao fetch e chame abort() na
limpeza. O sinal pode interromper trabalho pendente no cliente. Na analogia, a
comanda é riscada na mesa, mas a cozinha pode já ter começado; o cancelamento não
desfaz processamento do servidor nem resposta recebida.
useEffect(() => {
const controlador = new AbortController();
fetch(`${API}/turmas?nivel=${nivel}`, { signal: controlador.signal })
.then((r) => r.json())
.then((dados) => setTurmas(dados))
.catch((e) => {
if (e.name === 'AbortError') return;
setErro(e.message);
});
return () => controlador.abort();
}, [nivel]);O signal liga controlador e fetch; o return é a limpeza chamada antes do
próximo efeito ou da desmontagem. Se o fetch ainda estiver pendente quando for
abortado, sua Promise rejeita com AbortError. Esse cancelamento esperado não
deve virar mensagem de falha.
Experimente você mesmo
Dentro de <StrictMode>, somente no ambiente de desenvolvimento, o React executa
um ciclo extra de configuração, limpeza e nova configuração para testar o
efeito. Abra os logs, preveja essa ordem e marque qual das duas respostas deve
chegar ao estado; depois compare com a execução abaixo. ms() apenas mede o
tempo desde a montagem.
useEffect(() => {
const controlador = new AbortController();
console.log(ms(), 'efeito rodou ', nivel);
fetch(`${API}/turmas?nivel=${nivel}`, { signal: controlador.signal })
.then((r) => r.json())
.then((dados) => {
console.log(ms(), 'resposta usada', nivel);
setTurmas(dados);
})
.catch((e) => {
if (e.name === 'AbortError') console.log(ms(), 'abortado ', nivel);
else throw e;
});
return () => {
console.log(ms(), 'limpeza rodou ', nivel);
controlador.abort();
};
}, [nivel]);Duas chamadas de fetch saíram; a primeira foi abortada em 12 milissegundos e
só a segunda virou estado. Vale olhar do outro lado do fio: pus um contador na
rota /turmas da API — console.log('SERVIDOR recebeu pedido', ++n, req.query)
— e comparei esse ciclo do StrictMode com a troca de filtro da próxima seção,
onde o cancelamento chega bem mais tarde.
No teste do StrictMode, abort() agiu antes do envio e o servidor registrou um
acesso. Na troca de filtro, os dois pedidos já tinham partido: cancelar evitou
usar a resposta velha, não o trabalho iniciado no servidor. O ciclo diagnóstico
duplo não ocorre em produção, mas a mesma limpeza protege a troca rápida de filtro.
Até aqui: o efeito abre a sincronização com a rede, e sua limpeza avisa que a busca anterior perdeu o interesse. O ciclo extra do StrictMode existe apenas no desenvolvimento e ajuda a revelar limpeza ausente.
Como impedir que uma resposta antiga sobrescreva a nova?
Cancele a busca anterior na limpeza do efeito. Sem isso, duas Promises podem terminar fora de ordem e a mais antiga gravar dados sobre a seleção atual.
Esse defeito se chama condição de corrida: o resultado depende de qual tarefa termina primeiro. A secretaria seleciona “Básico” (900 ms) e, 100 ms depois, “Avançado” (150 ms). As duas requisições ficam no ar e voltam fora de ordem.
Sem cancelamento nenhum, com um log na saída e outro na chegada de cada uma. O
componente desta demonstração imprime o nível selecionado num <p> acima da
lista, para você ver as duas informações na mesma tela:
useEffect(() => {
console.log(ms(), 'pediu ', nivel);
fetch(`${API}/turmas?nivel=${nivel}`)
.then((r) => r.json())
.then((dados) => {
console.log(ms(), 'chegou', nivel, `(${dados.length} turmas)`);
setTurmas(dados);
});
}, [nivel]);Na última linha, o título diz avancado, mas a lista é do básico. A resposta nova chegou primeiro; depois, a antiga sobrescreveu o estado sem gerar erro. Os tempos confirmam a condição de corrida.
O mesmo teste, trocando só o efeito pela versão com AbortController:
A limpeza rodou quando o filtro mudou. O cliente deixou de esperar e usar a resposta do básico; se o servidor já tinha começado, seu trabalho pode ter continuado. O efeito visível é a tela terminar coerente.
Outra opção é marcar a busca antiga com uma variável booleana na limpeza:
useEffect(() => {
let atual = true;
fetch(`${API}/turmas?nivel=${nivel}`)
.then((r) => r.json())
.then((dados) => {
if (atual) setTurmas(dados);
});
return () => {
atual = false;
};
}, [nivel]);Ela resolve o sintoma — a resposta velha chega e é ignorada — mas o download
continua ocupando conexão e banda. Prefira o AbortController; guarde a
booleana para os casos em que o cliente HTTP não aceita signal.
Qual é a diferença entre erro de rede e status HTTP?
Falha de rede impede uma resposta HTTP utilizável e normalmente rejeita a
Promise. Já um status 404 ou 500 é uma resposta recebida: fetch não rejeita
somente por causa desse status, então o código precisa verificar resposta.ok.
Primeiro, peça um nível inexistente:
const resposta = await fetch(`${API}/turmas?nivel=intermediarrio`);
console.log('resposta.ok =', resposta.ok);
console.log('resposta.status=', resposta.status);
console.log('corpo =', JSON.stringify(await resposta.json()));ok é true para status de 200 a 299; status guarda o número 404;
resposta.json() interpreta o corpo JSON e devolve outra Promise.
Na analogia, o prato chegou, mas com a etiqueta 404. Para fetch, a entrega da
resposta aconteceu; interpretar esse status é trabalho do seu código. Por
isso o .then((r) => r.json()) do primeiro exemplo passa liso e entrega ao
estado um objeto { erro: ... } no lugar da lista. O estrago aparece no
render, e a mensagem é esta:
Já quando a API está fora do ar, o fetch rejeita de verdade:
try {
await fetch('http://localhost:4517/turmas?nivel=avancado');
} catch (e) {
console.log(e.name + ': ' + e.message);
console.log(' cause:', e.cause?.name, e.cause?.code);
for (const x of e.cause?.errors ?? []) console.log(' ', x.code, x.address + ':' + x.port);
}Tratar os dois casos custa uma linha de checagem e um try/catch. A função
abaixo mora dentro do efeito, logo depois de criar o controlador:
async function buscar() {
try {
const resposta = await fetch(`${API}/turmas?nivel=${nivel}`, {
signal: controlador.signal,
});
if (!resposta.ok) {
throw new Error(`a secretaria respondeu ${resposta.status}`);
}
setTurmas(await resposta.json());
} catch (e) {
if (e.name === 'AbortError') return;
setErro(e.message);
} finally {
if (!controlador.signal.aborted) setCarregando(false);
}
}O try agrupa a operação que pode falhar. Status fora da faixa de sucesso vira
erro explícito; no sucesso, o JSON alimenta setTurmas. O catch ignora apenas
o cancelamento esperado e guarda as outras mensagens. O finally executa nos
dois caminhos, mas sua guarda evita que uma busca abortada desligue o loading da
requisição mais nova.
Repare no finally: ele só desliga o “carregando” se a requisição não foi
abortada. Parece implicância, e não é. Rodei o mesmo componente com e sem essa
guarda, trocando de basico para avancado depois de 100 ms e imprimindo a
tela a cada 50 ms. Sem a guarda:
Com a guarda:
Sem a guarda, o finally da busca abortada desliga o loading enquanto a nova
ainda está no ar. Nas cinco execuções, a mensagem vazia ficou visível por 152 a
159 ms. Com a guarda, “Carregando turmas…” permanece até a resposta atual.
Até aqui: erro de rede chega pela rejeição da Promise; erro HTTP exige olhar
ok ou status; cancelamento esperado não vira erro de interface. A guarda no
finally mantém o loading da requisição atual.
Como buscar de novo quando o filtro muda?
O array no fim do useEffect lista as dependências, valores que React observa
para decidir se repete a sincronização. Inclua primitivos usados pela busca, como
nivel e turno; evite um objeto novo criado em cada renderização:
function PainelDeTurmas({ nivel }) {
const [turmas, setTurmas] = useState([]);
const filtros = { nivel, turno: 'noite' };
useEffect(() => {
fetch(`${API}/turmas?nivel=${filtros.nivel}`)
.then((r) => r.json())
.then(setTurmas);
}, [filtros]);
return <p>{turmas.length} turmas</p>;
}React compara dependências por identidade: cada render cria outro objeto
filtros, mesmo com campos iguais. O efeito busca, setTurmas renderiza, nasce
outro objeto e o ciclo recomeça. O contador mostra os três primeiros pedidos e o
total após três segundos sem interação:
Foram vinte requisições em três segundos. A correção é depender dos valores primitivos, não do objeto que os embrulha:
function PainelDeTurmas({ nivel, turno }) {
const [turmas, setTurmas] = useState([]);
useEffect(() => {
const controlador = new AbortController();
fetch(`${API}/turmas?nivel=${nivel}&turno=${turno}`, { signal: controlador.signal })
.then((r) => r.json())
.then(setTurmas)
.catch((e) => {
if (e.name !== 'AbortError') throw e;
});
return () => controlador.abort();
}, [nivel, turno]);
return (
<ul>
{turmas.map((t) => (
<li key={t.id}>{t.nome}</li>
))}
</ul>
);
}Mesmo contador, dois segundos de tela aberta, e o turno trocando de manha
para noite no meio do caminho:
Dois pedidos, um por mudança de filtro. Prefira dependências primitivas — string,
número e booleano. Se precisar de um objeto, desmonte seus campos ou estabilize
a referência com useMemo, hook que memoriza um valor entre renderizações. O
comportamento completo está na lição de
useEffect.
Quando extrair a busca para um hook?
Extraia quando estados, tratamento e cancelamento começarem a se repetir. Um hook customizado agrupa essa lógica e entrega ao componente apenas o resultado.
Quatro estados, função assíncrona, cancelamento, resposta.ok e dependências
somam cerca de trinta linhas. Repeti-las em várias telas aumenta o risco de cada
cópia corrigir um erro e esquecer outro.
O primeiro passo é useTurmas. Seu nome começa com use porque ele chama outros
hooks. O estado vira um objeto com um campo status — aqui, fase da interface,
não status HTTP — evitando combinações como “carregando e com erro”:
function useTurmas(nivel, turno) {
const [estado, setEstado] = useState({ status: 'carregando', turmas: [], erro: null });
useEffect(() => {
const controlador = new AbortController();
setEstado({ status: 'carregando', turmas: [], erro: null });
async function buscar() {
try {
const resposta = await fetch(`${API}/turmas?nivel=${nivel}&turno=${turno}`, {
signal: controlador.signal,
});
if (!resposta.ok) throw new Error(`a secretaria respondeu ${resposta.status}`);
const turmas = await resposta.json();
setEstado({ status: turmas.length ? 'ok' : 'vazio', turmas, erro: null });
} catch (e) {
if (e.name === 'AbortError') return;
setEstado({ status: 'erro', turmas: [], erro: e.message });
}
}
buscar();
return () => controlador.abort();
}, [nivel, turno]);
return estado;
}O hook inicia em carregando, repete o efeito quando os filtros mudam e percorre
as fases ok, vazio ou erro. A limpeza cancela a busca pendente, e return estado expõe o resultado sem expor os detalhes da rede.
O componente que usa esse hook cabe em dez linhas e não sabe nada sobre rede:
function PainelDeTurmas({ nivel, turno }) {
const { status, turmas, erro } = useTurmas(nivel, turno);
if (status === 'carregando') return <p>Carregando turmas…</p>;
if (status === 'erro') return <p>Não deu para carregar as turmas: {erro}</p>;
if (status === 'vazio') return <p>Nenhuma turma de {turno} neste nível.</p>;
return (
<ul>
{turmas.map((t) => (
<li key={t.id}>
{t.nome} — {t.vagas} vagas
</li>
))}
</ul>
);
}O componente desestrutura os três campos retornados e escolhe a interface pelo
status. Só o caminho ok chega ao map da lista.
Montei esse painel três vezes com turno="noite": num nível que tem turma à
noite, num nível sem turma nenhuma e num nível digitado errado.
O hook resolve repetição, e só. Ele não tem cache, uma cópia guardada de uma resposta para reutilização, nem revalida ao voltar para a aba ou tenta novamente após falha. Bibliotecas de dados oferecem esses recursos. Use esta régua:
| situação | abordagem | por quê |
|---|---|---|
| uma ou duas telas com dado, projeto de estudo | useEffect + fetch na mão |
você precisa entender o mecanismo antes de escondê-lo |
| a mesma busca em três telas | hook próprio, como o useTurmas |
tira a repetição sem trazer dependência nova |
| lista que várias telas mostram, com cache e revalidação | TanStack Query ou SWR | cache por chave, tentativa nova e dado compartilhado saem prontos |
o dado é da rota (/turmas/:id) |
loader do React Router | busca começa junto com a navegação, não depois do render |
| o projeto é Next.js ou similar | buscar no servidor | o HTML já sai com a lista dentro; não existe estado de carregando |
Numa tela, useEffect e fetch deixam o mecanismo visível. Quando outra tela
repetir a busca, extraia um hook customizado.
Quando várias telas precisarem compartilhar o dado carregado, considere uma
biblioteca de dados: cache é um problema separado da interface.
O que aprender depois de consumir uma API?
Na ordem 13, aprenda useRef: ele guarda um valor entre renderizações sem pedir
uma nova tela. Isso é útil para identificadores de temporizadores e outros
recursos externos que também precisam de limpeza.
Rode a API local, monte os quatro estados e provoque a corrida: clique em
“Básico” e logo depois em “Avançado”. Observe a tela antes e depois de ligar o
AbortController na limpeza.
Até aqui: você modelou quatro estados, tratou rede e status HTTP, cancelou buscas antigas, corrigiu dependências e extraiu um hook sem confundi-lo com cache.
O próximo passo da trilha de React é useRef no
React, onde você guardará, entre outros valores, o
identificador de um setTimeout que precisa ser cancelado.
Prefere aprender em vídeo?
Tem uma aula sobre este assunto no nosso canal.
Perguntas frequentes
Preciso do axios ou dá para usar só o fetch?
Onde eu guardo o endereço da API?
VITE_API_URL=http://localhost:4517 no arquivo .env e import.meta.env.VITE_API_URL no código. Lembre que tudo que vai para o front é público: chave secreta fica no back-end.Posso chamar a API direto no onClick, sem useEffect?
O React 19 não tem o use() para isso?
Dúvidas e comentários
Travou em algum passo? Pergunte aqui — a equipe e outros alunos respondem.
Entrar para perguntarÉ o mesmo login gratuito dos cursos.
Nenhuma dúvida por aqui ainda — a primeira pode ser a sua.
Todo o código deste artigo foi executado em Node 24.16.0 com React 19.2.8 e jsdom 30.0.1, e as saídas exibidas são as reais — como produzimos este conteúdo.
Fontes consultadas
- MDN — AbortController — developer.mozilla.org
- MDN — Usando a Fetch API — developer.mozilla.org
- React — useEffect: buscando dados — react.dev



