Pular para o conteúdo

react, performance, architecture11 min de leitura

A Otimização Mais Subestimada do React: Organização da Árvore de Componentes

O React faz exatamente o que você projeta para ele fazer. Quando uma aplicação parece lenta, o instinto é culpar o framework. Na maioria das vezes, o framework está bem. A árvore que ele recebeu não está.

Before memo, design the tree.

A árvore de componentes que você escreve determina quais componentes re-renderizam, com que frequência e a que custo. Onde o estado mora, quem renderiza o quê, como os componentes são compostos: são decisões estruturais com consequências diretas de performance. O React as segue fielmente, deliberadas ou não.

Na maioria das vezes, não são deliberadas. Elas se acumulam. Uma funcionalidade precisa de um dado, então o estado sobe. Outra funcionalidade precisa do mesmo dado, então ele fica ali. Um wrapper de layout cresce. Um Provider é colocado na raiz "por garantia". Seis meses depois, um useState que pertence dois níveis abaixo mora num pai que re-renderiza toda a sua subárvore a cada interação. Ninguém o colocou ali de propósito. Ninguém o moveu quando os sintomas apareceram.

Isso não é o React se comportando mal. Isso é o React fazendo exatamente o que foi mandado.

Ao final deste artigo, você vai:

  • entender por que a estrutura da árvore — não a memoização — é a alavanca principal para controlar re-renders
  • reconhecer dois padrões que eliminam renders desnecessários sem um único wrapper
  • ver o que os dados de benchmark mostram sobre o custo real de errar isso

Nenhuma API nova. A otimização é inteiramente arquitetural.

Re-renderizar não é o inimigo

Aqui está algo que a maioria dos tutoriais de React inverte: re-renderizar não é o problema.

Re-renderizar é como o React funciona. Quando o estado muda, o React chama a função do componente, gera uma nova descrição de UI, compara com a anterior, e atualiza o DOM onde algo difere. O ciclo é rápido, leve e exatamente correto. Re-renderizar não é um modo de falha — é o mecanismo que mantém sua UI sincronizada com seu estado. Uma aplicação React que não re-renderiza é uma aplicação quebrada.

O problema é o re-render desnecessário: componentes sendo chamados cuja saída não vai mudar, porque nada do que eles dependem mudou, porque o único motivo de terem rodado é que o pai deles rodou primeiro.

Isso acontece porque renderizar é recursivo. Quando um componente re-renderiza, o React chama seus filhos. E os filhos deles. E os filhos dos filhos. Não porque esses componentes tenham qualquer relação com o estado que mudou — mas porque estão downstream do componente que tem. O React não analisa a árvore para determinar quem de fato depende do quê. Ele propaga para baixo e confia no seu algoritmo de diffing para detectar se algo precisa atualizar.

Existem exatamente duas coisas que fazem um componente re-renderizar: o próprio estado dele muda, ou o pai dele re-renderiza. Essa é a lista completa.

Agora, por que o re-render desnecessário prejudica? Um re-render envolve duas fases distintas. A Render Phase chama as funções dos componentes, constrói o novo virtual DOM, e roda o algoritmo de diffing para identificar o que mudou — mais barato do que o que vem a seguir, mas não é grátis. A Commit Phase aplica essas mudanças identificadas ao DOM real. Operações de DOM estão entre as coisas mais custosas que um navegador executa: recálculo de layout, pintura, composição. O algoritmo de diffing O(n) do React existe justamente para minimizá-las.

O risco dos re-renders desnecessários não é só as chamadas de função extras. É o que essas chamadas podem disparar. Um componente que re-renderiza quando não deveria pode produzir uma saída que diverge do que o React tinha em cache — novas referências de objeto passadas como props para os filhos, valores derivados recalculados, nomes de classe ligeiramente diferentes. O diffing do React encontra essas diferenças e as aplica ao DOM real. Mutações que não teriam acontecido se o componente nunca tivesse sido chamado. O re-render desnecessário foi a condição. A mutação do DOM foi o custo.

O React.memo resolve isso adicionando uma comparação de props antes da chamada da função: se as props não mudaram, pula o render. É uma ferramenta válida. Mas ela compensa o fato do pai re-renderizar — não muda de onde a propagação começa. Antes de recorrer ao memo, pergunte: por que esse pai re-renderiza no contexto deste componente? Ele precisa?

O React Compiler responde essa pergunta com uma máquina. Ele insere a memoização para você, em cada nível e com mais precisão do que você faria à mão — bom o suficiente pra que os benchmarks deste artigo praticamente se fechem uma vez que ele está habilitado. Onde ele está habilitado, e onde seus componentes seguem as Rules of React o bastante pra compilar: os que não seguem são pulados, silenciosamente.

Ainda assim é compensação. O compilador faz cache em volta da árvore que você escreveu; ele não consegue decidir que um useState pertence dois níveis abaixo, ou que um Provider está alimentando cinquenta componentes que nunca o leem. Essas são as decisões sobre as quais este artigo trata, e nenhum compilador as toma por você.

Onde a propagação deveria parar

Todo re-render desnecessário é uma propagação que viajou mais longe do que o estado exigia. A pergunta estrutural é sempre a mesma: onde esse re-render deveria ter parado?

Dois padrões respondem essa pergunta de ângulos diferentes. Nenhum usa memoização. A otimização vem de redesenhar onde a fronteira fica — antes do render, não interceptando-o depois.

Movendo a origem

O caso mais comum: o estado mora mais alto do que o componente que o usa, e componentes sem relação estão downstream.

.jsx
// ❌ count mora em Parent — os irmãos caros re-renderizam a cada clique
function Parent() {
  const [count, setCount] = useState(0);
 
  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
      <ExpensiveComponent />
      <AnotherExpensiveComponent />
    </div>
  );
}

Parent guarda o estado. Todo clique o re-renderiza — o que re-renderiza ExpensiveComponent e AnotherExpensiveComponent. Nenhum dos dois tem qualquer relação com count. Eles não o leem. Eles não renderizam nada que dependa dele. Eles rodam porque estão downstream de um componente que mudou. Essa é a falha estrutural.

Mover o estado para baixo para a propagação no lugar certo.

.jsx
// ✅ count mora em Counter — os irmãos caros ficam intocados
function Counter() {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>;
}
 
function Parent() {
  return (
    <div>
      <Counter />
      <ExpensiveComponent />
      <AnotherExpensiveComponent />
    </div>
  );
}

Parent não tem estado. Ele nunca re-renderiza por conta própria. Quando o usuário clica, Counter re-renderiza — e a propagação para ali, porque ExpensiveComponent e AnotherExpensiveComponent são irmãos de Counter, não filhos dele. O formato da árvore é quem faz o trabalho.

Benchmark comparando o estado mantido no pai versus movido para Counter, ao longo de 100 cliques
Após 100 cliques: o estado em Parent tem média de 3.53ms por render (352.90ms no total). Movido para Counter, 0.08ms — 4.367% mais rápido — porque Parent renderiza uma única vez, na montagem, e nunca mais.

O princípio

Se mover o estado para baixo não quebra nada, ele deveria estar lá embaixo.

Bloqueando o caminho

Algum estado genuinamente não pode ser movido para baixo. O componente com estado é estrutural — ele precisa envolver o conteúdo caro como parte do layout. Mover o estado pra fora quebraria o propósito do componente.

O caso clássico: um input que filtra uma lista, onde os dois moram dentro do mesmo wrapper.

.jsx
// ❌ SearchLayout dono do estado — ExpensiveList re-renderiza a cada tecla
function SearchLayout() {
  const [query, setQuery] = useState('');
 
  return (
    <div>
      <input value={query} onChange={e => setQuery(e.target.value)} />
      <ExpensiveList />
    </div>
  );
}

Cada tecla digitada re-renderiza SearchLayout. ExpensiveList é filho dele — re-renderiza junto, a cada tecla, sem nenhuma mudança na sua saída.

Benchmark mostrando ExpensiveList re-renderizando a cada tecla porque é filho do SearchLayout, que tem estado
Após 100 teclas digitadas: média de 3.54ms, 353.60ms no total. ExpensiveList renderiza 100 vezes.

ExpensiveList renderiza 100 vezes. Não dá pra colocar o estado junto aqui. A maioria dos desenvolvedores instala React.memo em ExpensiveList e segue em frente. Mas a estrutura pode ser invertida.

.jsx
// ✅ StatefulInput recebe o conteúdo caro como children — sua referência fica estável
function StatefulInput({ children }) {
  const [query, setQuery] = useState('');
  return (
    <div>
      <input value={query} onChange={e => setQuery(e.target.value)} />
      {children}
    </div>
  );
}
 
function SearchLayout() {
  return (
    <StatefulInput>
      <ExpensiveList />
    </StatefulInput>
  );
}

StatefulInput é dono do estado — mas ExpensiveList é criado por SearchLayout e passado como children. Quando o usuário digita e StatefulInput re-renderiza, ele renderiza {children}. Esse children é o objeto de elemento React que SearchLayout produziu. SearchLayout não tem estado, não re-renderizou, e o elemento que ele passou carrega a mesma referência que carregava antes.

O React vê a mesma referência de elemento na mesma posição. Ele pula a chamada da função de ExpensiveList.

Benchmark mostrando ExpensiveList renderizando só uma vez depois de ser elevado para children, ao longo de 100 teclas
Mesmas 100 teclas digitadas: média de 0.08ms, 7.70ms no total. StatefulInput renderiza 100 vezes. ExpensiveList renderiza uma vez — uma diferença de 4.492%. Sem memo.

A árvore como decisão de design

Dois cenários, duas causas diferentes, duas correções diferentes. O padrão subjacente é idêntico em cada um: uma decisão estrutural — onde o estado mora, quem cria um componente — determina quais componentes re-renderizam quando algo muda. Acertar essa decisão elimina o problema. Errá-la cria um problema de performance que o memo consegue remendar, mas não resolver.

O estado pertence ao seu ancestral comum mais próximo — não ao mais conveniente. Todo nível acima disso é propagação desnecessária. Todo nível desnecessário é um lugar onde o memo eventualmente vai aparecer e ninguém vai lembrar por quê.

Frequência de atualização é uma dimensão de design. Um estado que muda a cada tecla e um estado que muda quando um modal abre têm custos de propagação completamente diferentes. Misturá-los no mesmo componente significa que o estado que muda rápido força re-renders em tudo que está downstream do que muda devagar também. A árvore deveria refletir taxas de mudança — não quais funcionalidades parecem pertencer juntas na tela.

Memoização é um sinal, não uma solução. Recorrer ao React.memo é reconhecer que um componente re-renderiza mais do que deveria, e que reestruturar não vale a pena agora. Às vezes essa é a decisão certa. Mas é um remendo — e remendos se acumulam. Uma base de código coberta de wrappers memo é uma base de código cheia de decisões estruturais que nunca foram de fato tomadas.

O React Compiler remove os wrappers, não as decisões — e remove o sintoma que costumava avisar que elas estavam pendentes. Uma árvore com estado no lugar errado deixa de ser lenta. Ainda é a árvore onde um valor passa por cinco componentes que nunca o leem, onde mover uma funcionalidade significa rastrear qual ancestral é dono do seu estado, e onde um novo engenheiro lê quatro arquivos pra responder uma pergunta que a estrutura deveria ter respondido. O compilador torna isso rápido. Não torna isso legível.

Partindo da árvore

A mudança que este artigo pede é sobre a ordem das perguntas.

Antes de abrir o profiler, antes de recorrer ao memo, pergunte: onde precisa morar o estado que está causando esse re-render? Se está mais alto do que seu consumidor mais próximo, mova para baixo. Se não pode ser movido, o componente caro pode ser passado como children a partir de um escopo externo estável?

Se você quer testar essa pergunta antes de escrever um único componente, o Treeact deixa você esboçar a árvore, marcar onde estado e children moram, e assistir a cascata de re-render antes que ela exista em código.

Essas perguntas são mais rápidas de responder do que depuração posterior. Elas deixam a base de código mais simples do que a encontraram. E elas endereçam a causa — não o sintoma.

A parte difícil não é aprender os padrões. É construir o hábito de tratar a árvore de componentes como uma decisão de design de primeira classe: algo em que você pensa antes de escrever um componente, não algo que você remenda depois que o profiler te avisa que algo está lento.

A maioria das árvores não é projetada. É acumulada.

Projete a árvore. memo é o que acontece quando você não projeta.