Isso combina com o seu sintoma?

  • O spinner fica girando e a página nunca é renderizada
  • O texto 'Loading...' nunca some
  • Atualizar não muda nada
  • O Console do DevTools (F12) mostra um erro em vermelho, ou a aba Network mostra uma requisição que falhou (em vermelho)

Um spinner infinito quase sempre significa a mesma coisa: os dados nunca terminaram de carregar e a flag de carregamento nunca foi desligada. Existem 4 causas comuns — e a maioria é resolvida com duas linhas de código.

Causa 1 — O carregamento nunca é limpo quando a requisição falha (a mais comum)

Código típico:

jsx

const [loading, setLoading] = useState(true);

useEffect(() => {
  fetch('/api/data')
    .then(res => res.json())
    .then(data => {
      setData(data);
      setLoading(false); // only runs on success
    });
}, []);

Se a requisição falha (queda de rede, bloqueio de CORS), a execução nunca chega ao .then, então setLoading(false) nunca é chamado e o spinner gira para sempre. Segundo a MDN, o fetch rejeita a promise em erros de rede e URLs inválidas (falhas de CORS também aparecem como erros de rede).

Solução: limpe o carregamento no finally

Envolva a chamada em try / catch / finally para que o bloco finally sempre rode — em caso de sucesso ou de falha. A MDN confirma que finally (e Promise.prototype.finally) roda tanto se a promise for cumprida quanto rejeitada, o que faz dele o lugar certo para limpezas como desligar a flag de carregamento.

jsx

useEffect(() => {
  const load = async () => {
    try {
      setLoading(true);
      const res = await fetch('/api/data');
      if (!res.ok) throw new Error(`HTTP ${res.status}`); // see Cause 2
      setData(await res.json());
    } catch (e) {
      setError(e.message);   // show an error state
    } finally {
      setLoading(false);     // always runs → spinner always stops
    }
  };
  load();
}, []);

Está usando uma cadeia de .then()? Adicione .finally(() => setLoading(false)) no final.

> Se for um erro de CORS ('blocked by CORS policy' na aba Network), o finally para o spinner, mas você ainda não vai receber os dados. A solução real é no servidor: a API precisa enviar um cabeçalho Access-Control-Allow-Origin — isso não dá para resolver pelo frontend.

Causa 2 — Um 404/500 é tratado como sucesso

O fetch() não lança erro em status HTTP de erro como 404 ou 500 — a promise é resolvida normalmente. Aí você tenta fazer res.json() de uma página de erro (HTML) e quebra, ou renderiza com dados vazios. MDN: uma promise de fetch não é rejeitada em um 404/504, então você mesmo precisa verificar Response.ok / Response.status.

Solução: sempre verifique res.ok

É a linha if (!res.ok) throw ... acima. Agora um 404 cai no catch, então você vê um estado de erro em vez de um spinner sem fim.

Causa 3 — useEffect entra em loop infinito

Um array de dependências errado faz o componente re-renderizar → refazer o fetch → re-renderizar… e o spinner não para.

Dois erros comuns:

jsx

useEffect(() => { /* ... */ });            // 🚩 no array → runs after every render
useEffect(() => { /* ... */ }, [options]); // 🚩 options is an object → new on every render

Documentação do React: objetos, arrays e funções são recriados a cada render, então colocá-los no array de dependências faz o valor ser diferente em cada commit — um loop infinito.

Solução

  • Rodar uma vez só → passe um array vazio []: useEffect(() => { ... }, []);
  • Crie objetos/funções dentro do Effect; mantenha só primitivos (strings, números) nas dependências:
jsx

useEffect(() => {
  const options = { userId, roomId }; // created inside the Effect
  // ...
}, [userId, roomId]); // primitives only
  • Atualize o estado a partir dele mesmo com a forma de updater: setCount(c => c + 1) (não setCount(count + 1) com [count], que corre o risco de gerar um loop).

Causa 4 — Respostas fora de ordem (troca rápida de aba/busca)

Mudar um input rapidamente pode fazer uma requisição antiga ser resolvida por último e deixar o estado travado. O padrão oficial do React usa uma flag ignore de limpeza:

jsx

useEffect(() => {
  let ignore = false;
  const load = async () => {
    try {
      const res = await fetch(`/api/data?id=${id}`);
      const data = await res.json();
      if (!ignore) setData(data);
    } finally {
      if (!ignore) setLoading(false);
    }
  };
  load();
  return () => { ignore = true; }; // cleanup: ignore stale responses
}, [id]);

Checklist de 3 minutos

  1. F12 → Console: algum erro em vermelho? Essa mensagem é a causa real.
  2. F12 → Network: alguma requisição que falhou (em vermelho)? É um 404 / 500 / erro de CORS?
  3. O setLoading(false) está dentro de um finally? Se não → Causa 1.
  4. Você verifica res.ok? Se não → Causa 2.
  5. O useEffect tem um array de dependências correto? Ausente ou um objeto → Causa 3.

> Solução confirmada: as causas 1–3 são as soluções padrão, verificadas na documentação oficial do React e da MDN. A maioria dos spinners infinitos é resolvida com duas linhas — 'limpar o carregamento no finally' + 'verificar res.ok'.