Você já abriu o Network DevTools e viu a mesma requisição disparando três vezes seguidas? Ou o app ficando gradualmente mais lento após alguns minutos de uso, sem nenhum erro no console? Antes de culpar o backend, olha os seus useEffect. A probabilidade de o problema estar ali é alta — e a causa quase sempre é o mesmo modelo mental errado.
Índice
- O modelo mental correto do useEffect
- Quando useEffect é a resposta certa
- 5 anti-padrões que você provavelmente está cometendo
- Memory leaks: o que acontece por baixo dos panos
- Cleanup function: o mecanismo que salva sua aplicação
- Array de dependências: a fonte de 80% dos bugs
- useEffectEvent no React 19: quando as deps te prendem
- Como auditar useEffects no seu projeto hoje
- FAQ
- Próximos passos
O modelo mental correto do useEffect
O maior erro não é sintático. É conceitual.
useEffect não é componentDidMount com nome diferente. Essa comparação — que todo tutorial de migração de classes para hooks fazia em 2019 — criou uma geração de devs usando o hook do jeito errado.
O modelo correto é este: useEffect sincroniza seu componente com um sistema externo. Rede, DOM, WebSocket, timer, biblioteca imperativa — qualquer coisa que vive fora do ciclo de renderização do React. Se não tem sistema externo envolvido, provavelmente não precisa de useEffect.
useEffect(() => {
// conecta com sistema externo
return () => {
// desconecta quando o componente sair ou as deps mudarem
};
}, [dependências]);
O ciclo completo é: mount → efeito roda → deps mudam → cleanup do efeito anterior → efeito roda de novo → unmount → cleanup final.
Entender esse ciclo resolve 90% das dúvidas sobre dependências e cleanup.
💡 React 18 + StrictMode: Em desenvolvimento, o React monta, desmonta e remonta cada componente propositalmente. Se seu efeito quebra nisso, vai quebrar em produção também — só que de forma menos óbvia. O StrictMode não é o problema. É o diagnóstico.
Quando useEffect é a resposta certa
Três cenários onde não tem atalho — você precisa do efeito.
Subscrições e conexões persistentes:
useEffect(() => {
const socket = new WebSocket('wss://api.seuapp.com/live');
socket.onmessage = (event) => {
setNotifications((prev) => [...prev, JSON.parse(event.data)]);
};
return () => socket.close();
}, []);
Eventos do DOM fora do controle do React:
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') closeModal();
};
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [closeModal]);
Bibliotecas imperativas (mapas, editores, players):
useEffect(() => {
const editor = new MonacoEditor(containerRef.current, { language: 'typescript' });
return () => editor.dispose();
}, []);
Nesses três casos, o efeito é inevitável. O React precisa saber quando conectar e quando desconectar — e só você pode fornecer essa lógica.
5 anti-padrões que você provavelmente está cometendo
Esses padrões aparecem em code review toda semana. Cada um deles força uma renderização extra que não precisava existir — ou esconde um bug que vai aparecer em produção.
1. Derivar estado de props ou state
// ❌ Renderização dupla desnecessária
useEffect(() => {
setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);
// ✓ Variável comum — zero custo extra
const fullName = `${firstName} ${lastName}`;
Se o valor pode ser calculado a partir de dados que o componente já tem, ele não precisa ser estado. Use uma variável local, useMemo se o cálculo for pesado, ou um seletor se vier de um store.
2. Resetar estado quando prop muda
// ❌ Causa render com estado velho antes de limpar
useEffect(() => {
setComment('');
}, [postId]);
// ✓ key prop desmonta e remonta — estado começa zerado
<CommentBox key={postId} />
3. Buscar dados sem proteção contra race condition
// ❌ Se userId mudar rápido, a resposta mais lenta vence
useEffect(() => {
fetchUser(userId).then(setUser);
}, [userId]);
Detalhado na seção de memory leaks.
4. Notificar o pai quando estado muda
// ❌ Sinal de que o estado está no lugar errado
useEffect(() => {
onDataChange(localData);
}, [localData]);
Se o pai precisa saber, o estado provavelmente deveria morar no pai. Ou num contexto compartilhado.
5. Buscar dados sem biblioteca de cache
Fetch manual em useEffect funciona. Mas não tem cache, não tem deduplicação, não tem retry, não trata race condition. Em 2026 não tem argumento para fazer isso em código de produção.
// ❌ Frágil, sem cache, sem race condition handling
useEffect(() => {
fetch(`/api/produtos/${id}`)
.then(r => r.json())
.then(setProduto);
}, [id]);
// ✓ React Query resolve tudo isso em duas linhas
const { data: produto } = useQuery({
queryKey: ['produto', id],
queryFn: () => fetchProduto(id),
});
Memory leaks: o que acontece por baixo dos panos
Um memory leak no React tem uma anatomia simples: o componente desmonta, mas algo continua rodando e tentando interagir com ele.
No React 18, o warning "Can't perform a React state update on an unmounted component" foi removido. O comportamento problemático continua acontecendo — só parou de gritar no console. Isso torna os leaks mais silenciosos, não menos perigosos.
O cenário mais comum em apps reais é o campo de busca com autocomplete:
// ❌ Race condition clássica em campo de busca
useEffect(() => {
setLoading(true);
buscarProdutos(query).then((data) => {
setProdutos(data);
setLoading(false);
});
}, [query]);
O usuário digita "note", depois "notebook". Duas requisições saem. A resposta de "note" chega depois da de "notebook" (acontece mais do que você imagina em conexões lentas). Resultado: a tela mostra produtos de "note" com o input mostrando "notebook". Estado inconsistente, silencioso, difícil de reproduzir localmente.
Cada setInterval não limpo consome memória e CPU por toda a sessão do usuário. Em componentes que montam e desmontam com frequência — modais, tabs, itens de lista — o acúmulo é real e mensurável no Memory Profiler do Chrome DevTools.
Cleanup function: o mecanismo que salva sua aplicação
A função de cleanup roda em dois momentos: antes do efeito rodar de novo (quando uma dep muda) e quando o componente desmonta. É o único lugar certo para cancelar, fechar e remover.
Fetch com AbortController — o padrão correto:
useEffect(() => {
const controller = new AbortController();
buscarProdutos(query, { signal: controller.signal })
.then((data) => setProdutos(data))
.catch((err) => {
if (err.name === 'AbortError') return;
setError(err.message);
});
return () => controller.abort();
}, [query]);
Agora cada nova digitação cancela a requisição anterior antes de disparar a próxima. Race condition eliminada.
Timers — o erro que todo mundo comete pelo menos uma vez:
// ❌ Interval roda para sempre depois que o componente sai
useEffect(() => {
setInterval(() => tick(), 1000);
}, []);
// ✓ Guarda o id e limpa no cleanup
useEffect(() => {
const id = setInterval(() => tick(), 1000);
return () => clearInterval(id);
}, []);
Num dashboard com vários widgets de atualização automática, esquecer um clearInterval significa que o polling continua rodando mesmo depois do usuário navegar para outra página. Multiplica isso por dez abas abertas e você tem um app que devora bateria de notebook.
Bibliotecas que criam instâncias:
useEffect(() => {
const chart = new Chart(canvasRef.current, config);
return () => chart.destroy();
}, [config]);
Chart.js, Leaflet, Monaco, qualquer lib que cria instâncias imperativas precisa de cleanup explícito. Sem isso, cada remount cria uma nova instância sem destruir a anterior.
⚠️ Atenção: O cleanup precisa ser síncrono. Não retorne uma Promise na função de cleanup. Se precisar de lógica assíncrona, dispara ela mas não aguarda.
Array de dependências: a fonte de 80% dos bugs
O eslint-plugin-react-hooks existe porque humanos erram esse array o tempo todo. A regra exhaustive-deps não é burocracia — é a única forma automática de detectar valores stale no closure.
Dep faltando — valor travado no primeiro render:
// ❌ userId nunca atualiza no efeito
useEffect(() => {
fetchUser(userId).then(setUser);
}, []); // userId está stale para sempre
// ✓ Dep declarada, efeito re-roda quando userId muda
useEffect(() => {
fetchUser(userId).then(setUser);
}, [userId]);
O problema oposto — dep que muda em todo render:
// ❌ config é um objeto novo a cada render — loop infinito
const config = { endpoint: '/api', timeout: 3000 };
useEffect(() => {
initService(config);
}, [config]); // nova referência toda vez → efeito roda infinito
// ✓ Mova o objeto para dentro do efeito ou use useMemo
useEffect(() => {
const config = { endpoint: '/api', timeout: 3000 };
initService(config);
}, []);
Funções têm o mesmo problema. Se você precisa de uma função como dep, useCallback estabiliza a referência. Se a função só é usada dentro do efeito, mova-a para dentro.
⚠️ Atenção: Nunca silencie
exhaustive-depscomeslint-disable-next-linesem entender exatamente o que está suprimindo. Em 9 de 10 casos, o lint está correto e você está enterrando um bug para o próximo dev encontrar.
useEffectEvent no React 19: quando as deps te prendem
Existe um padrão frustrante: você precisa de um valor dentro do efeito, mas não quer que mudanças nesse valor redisparem o efeito.
O caso clássico é um callback de analytics:
// ❌ logAnalytics entra nas deps, efeito roda em todo render
useEffect(() => {
logAnalytics('page_view', { page: currentPage, userId });
}, [currentPage, logAnalytics, userId]);
// Resultado: log duplicado sempre que userId mudar junto com currentPage
No React 19, useEffectEvent resolve isso:
import { useEffect, experimental_useEffectEvent as useEffectEvent } from 'react';
const onPageView = useEffectEvent(() => {
logAnalytics('page_view', { page: currentPage, userId });
});
useEffect(() => {
onPageView();
}, [currentPage]); // só dispara quando a página muda
useEffectEvent cria uma função que sempre lê os valores mais atuais — sem precisar estar no array de deps. É a solução oficial para o problema de "preciso desse valor mas não quero que ele seja uma dep".
Se você ainda está no React 18, a alternativa é useRef para guardar o callback:
const callbackRef = useRef(logAnalytics);
useLayoutEffect(() => { callbackRef.current = logAnalytics; });
useEffect(() => {
callbackRef.current('page_view', { page: currentPage });
}, [currentPage]);
Funciona, mas é verboso. useEffectEvent existe para tornar isso desnecessário.
Como auditar useEffects no seu projeto hoje
Abra o projeto agora e faça ctrl+shift+f por useEffect. Para cada resultado, responda:
Pergunta 1: Esse efeito sincroniza com um sistema externo?
Se a resposta for não (está derivando estado, reagindo a mudanças internas, notificando o pai), o useEffect está no lugar errado. Reescreva sem ele.
Pergunta 2: Existe algo que precisa ser cancelado, fechado ou removido quando o componente sair?
Se sim e não tem return () => ..., você tem um leak em potencial.
Pergunta 3: O eslint-plugin-react-hooks está reclamando de alguma dep? Cada warning é um bug esperando para aparecer em produção. Corrija — não silencie.
Pergunta 4: Tem objeto ou função literal no array de deps?
useEffect(() => { ... }, [{ id: userId }]); // nova referência sempre
Isso causa loop infinito. Coloque o primitivo (userId) direto, não o objeto.
Pergunta 5: O efeito faz fetch sem AbortController? Se o componente recebe props que podem mudar rapidamente (id de rota, termo de busca), você tem uma race condition ativa.
| Sintoma | Causa provável | Solução |
|---|---|---|
| Requisição duplicada em StrictMode | Sem cleanup no fetch | AbortController |
| Estado mostrando dado antigo | Dep faltando no array | Adicionar dep |
| Loop infinito de renders | Objeto/função no array de deps | Primitivo ou useMemo |
| App lento após navegação | setInterval sem clearInterval | Cleanup function |
| Valor stale dentro do efeito | Dep faltando ou useCallback ausente | exhaustive-deps |
FAQ
Por que o useEffect roda duas vezes no React 18?
É o StrictMode em desenvolvimento. O React desmonta e remonta cada componente propositalmente para expor efeitos que não implementam cleanup. Em produção isso não acontece. Se o comportamento duplo quebra algo na sua aplicação, o problema é a ausência de cleanup — não o StrictMode.
Qual a diferença entre useEffect e useLayoutEffect?
useEffect roda de forma assíncrona, depois que o browser pintou a tela. useLayoutEffect roda de forma síncrona, antes da pintura — bloqueando o browser até terminar. Use useLayoutEffect apenas quando precisar medir o DOM ou fazer mutações visuais antes que o usuário veja a tela, como posicionar um tooltip ou calcular scroll. Para tudo mais, useEffect.
Posso usar async/await diretamente no useEffect?
Não. A callback do useEffect não pode ser async porque precisa retornar uma função de cleanup ou undefined — não uma Promise. A solução é criar uma função async interna:
useEffect(() => {
async function carregar() {
const data = await fetchProduto(id);
setProduto(data);
}
carregar();
}, [id]);
useEffect roda no servidor com Next.js?
Não. useEffect é exclusivo do cliente — nunca roda em SSR. Para buscar dados no servidor com Next.js App Router, use Server Components ou fetch diretamente nas funções de rota. Fazer fetch em useEffect para dados críticos da página inicial causa flash de conteúdo e prejudica o LCP.
Como saber se tenho memory leaks nos meus efeitos?
Abra o Chrome DevTools > Memory > Heap Snapshot. Navegue para uma página com useEffect, depois saia e volte algumas vezes. Tire um novo snapshot e procure por listeners de evento ou timers acumulados. Para detecção em desenvolvimento, o React DevTools Profiler mostra quantas vezes cada componente renderizou e por quê.
Devo usar React Query em todo projeto ou só em apps grandes?
A partir do momento que você tem mais de dois useEffect fazendo fetch, React Query ou SWR já se pagam. O setup é pequeno e você elimina toda a lógica manual de loading, error, cache e race condition. A pergunta não é "meu app é grande o suficiente" — é "quero escrever toda essa lógica na mão?".
Próximos passos
Esta semana:
- Ative
exhaustive-deps: "error"no seu ESLint — trate warnings como erros e corrija um por um - Rode a auditoria da seção acima no projeto atual e classifique cada
useEffect
Este mês:
- Se você ainda faz fetch manual em
useEffect, migre para React Query ou SWR — comece pelos endpoints mais usados - Se usa React 19, teste
useEffectEventnos casos em que você está usandouseRefcomo workaround para deps
Para aprofundar:
- Synchronizing with Effects — documentação oficial reescrita com o modelo mental correto
- You Might Not Need an Effect — lista extensa de anti-padrões com alternativas