portfólio
← voltar

Por que o React re-renderiza: o modelo mental completo

Entenda o que realmente dispara um re-render no React, por que descendentes re-renderizam mesmo sem props mudarem, como funciona o batching, a reconciliação, o diffing e como o context e o React.memo se encaixam nesse modelo.

Matheus Pergoli··15 min de leitura

Re-render é o mecanismo central do React. É o que mantém a interface sincronizada com o estado da aplicação.

E ainda assim, é um dos pontos mais mal compreendidos por desenvolvedores React. Pergunte a um grupo de devs o que dispara um re-render e você vai ouvir respostas diferentes — a maioria incompletas, muitas incorretas.

O problema não é que o assunto seja difícil. É que a maioria das explicações param no "o quê" sem entrar no "por quê". E sem o "por quê", o modelo mental fica com lacunas que aparecem nas horas erradas.

Este artigo vai montar o modelo inteiro. Render phase, commit phase, reconciliação, diffing, batching, context, React.memo — tudo conectado, sem pular etapas.

O único gatilho real de um re-render

Existe uma verdade fundamental que simplifica tudo:

Todo re-render no React começa com uma mudança de estado.

Não é mudança de prop. Não é mudança de context diretamente. É estado.

Quando o setter de um useState é chamado, quando useReducer despacha uma action, quando o valor de um context muda — em todos esses casos, em última instância, há um estado mudando em algum componente da árvore.

JSX
function Contador() {
  const [count, setCount] = React.useState(0)
 
  // setCount é o único gatilho. Sem chamada de setCount, sem re-render.
  return (
    <button onClick={() => setCount(count + 1)}>
      {count}
    </button>
  )
}

Quando setCount é chamado, o React agenda uma atualização para o componente Contador. Essa atualização vai entrar em um ciclo de processamento que o React chama internamente de work loop — gerenciado nesse arquivo (bem grande) aqui ReactFiberWorkLoop.js.

O que "re-renderizar" significa de verdade

Antes de avançar, vale estabelecer exatamente o que acontece quando o React renderiza um componente.

Renderizar não é atualizar o DOM. Renderizar é chamar a função componente para produzir uma descrição de como a UI deveria estar.

O JSX no return é transformado pelo compilador em chamadas de React.createElement, que produzem objetos simples — os chamados React Elements:

JS
// O que esse JSX:
<button onClick={handleClick}>{count}</button>
 
// Vira depois de compilado:
React.createElement("button", { onClick: handleClick }, count)
 
// Que produz um objeto como:
{
  type: "button",
  props: {
    onClick: handleClick,
    children: 0
  }
}

Esse objeto é leve. É só uma descrição do que deveria aparecer na tela — não o DOM em si.

A atualização do DOM real acontece depois, em uma fase separada. O render produz a descrição. A outra fase usa essa descrição para modificar o DOM.

Essas são as duas fases que estruturam todo o processo:

Entender essa separação é essencial para o restante do artigo.

A render phase: construindo o snapshot

Quando o estado de um componente muda, o React precisa descobrir o impacto dessa mudança na UI.

Para isso, ele executa a render phase: chama a função do componente cujo estado foi alterado e, em cascata, chama as funções de todos os seus descendentes.

O resultado é uma árvore de elementos — um snapshot de como a UI deveria estar naquele momento.

TXT
Estado muda em Counter

React chama Counter()        → produz elementos

React chama BigCountNumber() → produz elementos

React chama Decoration()     → produz elementos

Snapshot completo da subárvore

O ponto de entrada da render phase é a função beginWork, definida em ReactFiberBeginWork.js. É ela que, dado um fiber (a representação interna de um componente), decide como processá-lo. Para function components, ela delega para renderWithHooks, definida em ReactFiberHooks.js — que é a função que efetivamente chama a função componente que você escreveu.

Por que descendentes re-renderizam sem as props mudarem

Esse é o ponto que mais confunde.

A intuição errada é: "se as props de um componente não mudaram, ele não precisa re-renderizar".

A intuição correta exige entender o que o React precisaria fazer para verificar isso.

Para saber quais props Decoration vai receber, o React tem que executar Counter primeiro — porque Decoration é construído dentro do JSX retornado por Counter. Só depois de executar Counter o React sabe o que está sendo passado para os filhos.

Mas se o React já executou Counter para descobrir as props, o trabalho de render já começou. O que resta é decidir se o resultado vai ser usado ou descartado — não se o render vai acontecer.

Há outra razão ainda mais fundamental: o React não pode assumir que um componente é puro. Componentes podem depender de valores externos ao seu estado e props:

JSX
function HoraAtual() {
  // Sem estado. Sem props. Mas produz resultados diferentes a cada chamada.
  const agora = new Date()
  return <p>São {agora.toLocaleTimeString()}</p>
}

Se o React assumisse que "props iguais = output igual", deixaria de re-renderizar HoraAtual quando o pai re-renderizasse — e a UI ficaria desatualizada.

A decisão padrão do React é conservadora: quando um componente re-renderiza, todos os seus descendentes também re-renderizam. Isso garante que o snapshot nunca vai estar desatualizado.

O comportamento padrão prioriza correção sobre performance. A performance pode ser adicionada depois, de forma explícita.

O que é um Fiber

Antes de entrar na commit phase, vale introduzir um conceito que aparece em toda a arquitetura interna do React: o Fiber.

Fiber é a representação interna de cada elemento da árvore. Não é o elemento React (o objeto { type, props } que o JSX produz) — é uma estrutura mais rica que o React usa para rastrear o trabalho a ser feito.

Cada fiber guarda, entre outras coisas:

Esse último ponto é importante. O React mantém duas árvores de fibers em paralelo:

Quando o render termina e o commit é executado, a work-in-progress tree passa a ser a current tree — e a antiga current tree fica disponível para ser reutilizada como a próxima work-in-progress tree.

Essa estrutura de dupla árvore é o que permite o React comparar o estado anterior com o novo, fazer o diff, e aplicar só o que mudou — sem precisar recriar nada do zero.

A commit phase: reconciliação e diffing

A render phase produz a work-in-progress tree. A commit phase usa essa árvore para atualizar o DOM.

Mas o React não destrói o DOM e reconstrói do zero a cada re-render. Em vez disso, ele compara a work-in-progress tree com a current tree e aplica apenas as diferenças.

Esse processo de comparação é a reconciliação. O algoritmo que faz essa comparação é o diffing. O código que executa a commit phase está em ReactFiberCommitWork.js.

Como o diffing funciona

O React compara as duas árvores nó a nó, de cima para baixo, seguindo regras específicas.

Regra 1: tipo diferente → desmonta tudo e recria do zero.

Se o tipo do elemento mudou — de div para section, de Button para Link — o React descarta toda a subárvore que estava ali e cria tudo do zero, incluindo o estado interno dos filhos.

JSX
// Render anterior
<div>
  <Filho />
</div>
 
// Render atual — tipo mudou de div para section
<section>
  <Filho />
</section>

Aqui, Filho vai ser desmontado completamente — seus cleanups de useEffect vão rodar — e remontado do zero. O estado interno de Filho é perdido.

Isso acontece porque, quando o tipo muda, o React não tem como saber se o conteúdo faz sentido ser reutilizado.

Regra 2: mesmo tipo → atualiza somente o que mudou.

Se o tipo do elemento é o mesmo, o React reutiliza o nó existente no DOM e só atualiza as propriedades que mudaram.

JSX
// Render anterior
<button className="btn" disabled={false}>Salvar</button>
 
// Render atual
<button className="btn btn-primary" disabled={true}>Salvar</button>

O React vê que é o mesmo tipo (button), então não recria o elemento. Ele só aplica as diferenças: atualiza className e define disabled. O texto "Salvar" não é tocado porque não mudou.

Para componentes React, o mesmo princípio vale: se o tipo é o mesmo, o React reutiliza a instância, preserva o estado e só passa as novas props.

Regra 3: listas e a prop key.

Quando o React compara listas de elementos, ele usa a prop key para identificar quais itens são os mesmos entre renders.

JSX
// Render anterior
<ul>
  <li key="a">Item A</li>
  <li key="b">Item B</li>
</ul>
 
// Render atual — um item foi inserido no começo
<ul>
  <li key="c">Item C</li>
  <li key="a">Item A</li>
  <li key="b">Item B</li>
</ul>

Com a key, o React sabe que Item A e Item B são os mesmos elementos de antes, só mudaram de posição. Ele os move no DOM em vez de recriar.

Sem a key, o React compara por posição: o primeiro filho do render anterior com o primeiro filho do render atual, e assim por diante. Isso pode gerar comportamentos incorretos e desnecessariamente destrutivos, especialmente quando itens têm estado interno.

Usar índice de array como key tem o mesmo problema: o índice muda quando a ordem da lista muda, e o React trata isso como elementos diferentes.

As três sub-fases do commit

A commit phase não é uma operação única. Ela acontece em três sub-fases executadas sequencialmente:

Before mutation: o React lê o estado atual do DOM antes de qualquer mudança. Em class components, é aqui que getSnapshotBeforeUpdate roda.

Mutation: o React aplica as mudanças no DOM — inserções, atualizações, deleções. É aqui que o DOM real é modificado.

Layout: o React roda os efeitos síncronos após as mutações. É aqui que useLayoutEffect roda — e é por isso que ele tem acesso ao DOM já atualizado, ao contrário do useEffect.

O useEffect não roda durante a commit phase. Ele é agendado para rodar de forma assíncrona depois que o browser termina de pintar — o que significa que o usuário já viu a tela atualizada quando o useEffect executa.

Essa distinção importa quando você precisa medir o DOM ou sincronizar com uma biblioteca externa. useLayoutEffect garante que você está lendo o DOM depois das mutações, mas antes do browser pintar. useEffect garante que você não está bloqueando a pintura.

Batching: como o React agrupa múltiplos setStates

Imagine que você tem um handler que chama setState três vezes seguidas:

JSX
function handleClick() {
  setCount(c => c + 1)
  setFlag(f => !f)
  setNome("Matheus")
}

A intuição inicial é que isso causaria três re-renders. Na prática, o React agrupa todas essas atualizações em um único re-render. Isso é o batching.

O batching existe porque a render phase e a commit phase bloqueiam o browser durante a execução. Agrupar múltiplos setState em um único render significa bloquear o browser uma vez ao invés de três.

Como o batching funciona internamente

O React mantém um conceito de execution context — um estado global que indica que tipo de trabalho está acontecendo no momento. Quando você está dentro de um event handler do React (um onClick, por exemplo), o React seta esse contexto para indicar que está em modo de batching.

Quando setState é chamado dentro desse contexto, o React não processa o re-render imediatamente. Ele coloca a atualização em uma fila de updates do fiber e continua executando o restante da função. Só quando o handler termina é que o React processa todas as atualizações da fila de uma vez, em um único render.

JSX
function handleClick() {
  setCount(c => c + 1) // enfileirado
  setFlag(f => !f)     // enfileirado
  setNome("Matheus")   // enfileirado
  // handler termina → React processa as três em um único render
}

Automatic Batching no React 18

Antes do React 18, o batching automático só funcionava dentro de event handlers do React. Se você chamasse setState dentro de um setTimeout, uma Promise ou um listener nativo, cada chamada disparava um render separado:

JSX
// React 17: dois re-renders separados
setTimeout(() => {
  setCount(c => c + 1) // re-render
  setFlag(f => !f)     // re-render
}, 1000)

No React 18, o batching passou a ser automático em qualquer contexto:

JSX
// React 18: um único re-render
setTimeout(() => {
  setCount(c => c + 1) // enfileirado
  setFlag(f => !f)     // enfileirado
  // React processa as duas em um único render
}, 1000)

Isso foi possível porque o React 18 mudou a forma como o scheduler prioriza e agrupa trabalho, mas o efeito observável é simplesmente que o batching funciona em todo lugar.

Se em algum caso específico você precisar forçar dois renders separados, existe o flushSync:

JSX
import { flushSync } from "react-dom"
 
flushSync(() => {
  setCount(c => c + 1) // força um render imediato e síncrono
})
// DOM já foi atualizado aqui
 
flushSync(() => {
  setFlag(f => !f) // força outro render imediato e síncrono
})

flushSync sai do batching e força a execução síncrona da render phase e da commit phase inteiras. Use com cuidado — ele bloqueia o browser até o processo terminar.

React.memo: como o React pula renders de descendentes

O comportamento padrão — todos os descendentes re-renderizam com o pai — é correto, mas pode ser otimizado em casos específicos.

React.memo é o mecanismo para isso. Quando um componente está envolto com React.memo, o React faz uma comparação das props antes de decidir se vai re-renderizar.

JSX
const Decoration = React.memo(function Decoration() {
  return <div className="decoration">⛵️</div>
})

O algoritmo de comparação do React.memo

A comparação que React.memo faz é shallow equality — igualdade superficial. Ela percorre as chaves do objeto de props e compara cada valor com Object.is:

JS
// Simplificação do que React.memo faz internamente
function shallowEqual(propsAnterior, propsNova) {
  if (Object.is(propsAnterior, propsNova)) return true
 
  if (
    typeof propsAnterior !== "object" || propsAnterior === null ||
    typeof propsNova !== "object"     || propsNova === null
  ) {
    return false
  }
 
  const keysAnterior = Object.keys(propsAnterior)
  const keysNova = Object.keys(propsNova)
 
  if (keysAnterior.length !== keysNova.length) return false
 
  for (let i = 0; i < keysAnterior.length; i++) {
    const chave = keysAnterior[i]
 
    if (
      !Object.prototype.hasOwnProperty.call(propsNova, chave) ||
      !Object.is(propsAnterior[chave], propsNova[chave])
    ) {
      return false
    }
  }
 
  return true
}

Note que o React usa Object.is em vez de ===. A diferença é sutil mas importante em edge cases:

JS
Object.is(NaN, NaN) // true  — === retornaria false
Object.is(+0, -0)   // false — === retornaria true

Para a maioria dos valores que você usa como props, Object.is e === se comportam de forma idêntica. Mas o React usa Object.is porque é o comportamento semanticamente correto para comparação de valores em JavaScript — o mesmo algoritmo que useState usa para decidir se o estado realmente mudou antes de agendar um re-render.

Se shallowEqual retornar true, o React descarta o render do componente e reutiliza o snapshot anterior. Se retornar false, segue com o render normalmente.

Quando React.memo falha silenciosamente

React.memo falha quando as props são objetos, arrays ou funções criados no corpo do componente pai:

JSX
function Pai() {
  const [count, setCount] = React.useState(0)
 
  const config = { tema: "dark" }         // nova referência a cada render
  const handleClick = () => doSomething() // nova referência a cada render
  const items = [1, 2, 3]                 // nova referência a cada render
 
  return (
    <>
      <button onClick={() => setCount(c => c + 1)}>+</button>
      {/* React.memo nunca vai "ativar" — as props são sempre diferentes */}
      <FilhoMemo config={config} onClick={handleClick} items={items} />
    </>
  )
}

O shallowEqual compara propsAnterior.config com propsNova.config usando Object.is. Como cada render cria um novo objeto { tema: "dark" } em um endereço de memória diferente, Object.is retorna false. O componente re-renderiza, mesmo com conteúdo idêntico.

O comparador customizado

React.memo aceita um segundo argumento: uma função de comparação customizada.

JSX
const FilhoMemo = React.memo(Filho, (propsAnterior, propsNova) => {
  // Retorna TRUE se as props são "iguais" → não re-renderiza
  // Retorna FALSE se as props são "diferentes" → re-renderiza
  return propsAnterior.id === propsNova.id
})

A semântica é invertida em relação à intuição: retornar true significa "são iguais, pode pular". Use quando o shallow equality não captura corretamente o que "mudou" para aquele componente específico — por exemplo, quando uma prop é um objeto profundo e você só se importa com um campo.

Context: props invisíveis

Por padrão, quando um componente pai re-renderiza, todos os filhos re-renderizam. O context não muda esse comportamento — ele apenas torna certos valores disponíveis para qualquer descendente que queira consumi-los, sem a necessidade de prop drilling.

O que o context adiciona é a capacidade de disparar re-renders em componentes que consomem aquele contexto, mesmo que o pai direto não tenha re-renderizado.

Como o React rastreia dependências de contexto

Quando um componente chama useContext(MeuContext), o React registra esse fiber como dependente do contexto MeuContext. Isso acontece dentro da função readContext, definida em ReactFiberNewContext.js.

O fiber passa a carregar uma lista de dependências de contexto — o campo dependencies — que o React verifica quando o valor do contexto muda:

TXT
Fiber do componente
  ├── type
  ├── memoizedState
  ├── pendingProps
  └── dependencies
        └── { context: MeuContext, memoizedValue: valorAnterior, next: ... }

Quando o valor de um Provider muda, o React percorre a árvore de fibers e verifica quais têm esse contexto no campo dependencies. Os que têm são marcados para re-render — mesmo que o pai direto não tenha sido afetado.

O React compara o novo valor do contexto com o valor armazenado em memoizedValue usando Object.is. Se forem iguais, nenhum re-render é disparado. Se forem diferentes, todos os consumidores registrados são agendados para re-render.

Esse é o motivo pelo qual passar um objeto novo como value de um Provider — mesmo com o mesmo conteúdo — dispara re-renders em todos os consumidores: Object.is vê referências diferentes.

JSX
function Provider({ children }) {
  const [user, setUser] = React.useState(null)
 
  // ❌ Novo objeto a cada render → todos os consumidores re-renderizam
  return (
    <UserContext.Provider value={{ user, setUser }}>
      {children}
    </UserContext.Provider>
  )
}

A solução é memoizar o value do Provider com useMemo, para que a referência só mude quando os dados que compõem o objeto realmente mudarem.

React.memo não protege contra re-renders de contexto

Aqui vai um detalhe que confunde: React.memo não protege um componente de re-renders causados por mudanças de contexto.

JSX
const Filho = React.memo(function Filho() {
  const user = React.useContext(UserContext)
  return <p>{user.nome}</p>
})

Se UserContext mudar, Filho vai re-renderizar — independentemente de React.memo. Isso é correto: o componente tem uma dependência de dados no contexto. Protegê-lo de re-renderizar quando esses dados mudam resultaria em UI desatualizada.

React.memo só compara props recebidas pelo componente. Context é uma dependência separada, rastreada internamente via o campo dependencies do fiber. As duas proteções são ortogonais.

Se você quiser otimizar componentes que consomem contexto, a abordagem é dividir o contexto em partes menores:

JSX
// ❌ Um único contexto — qualquer mudança dispara re-render em todos os consumidores
const AppContext = React.createContext({ user, theme, notifications })
 
// ✅ Contextos separados — cada consumidor re-renderiza só quando os dados que usa mudam
const UserContext = React.createContext(user)
const ThemeContext = React.createContext(theme)
const NotificationsContext = React.createContext(notifications)

Um componente que consome só ThemeContext não vai re-renderizar quando user muda. Ele está registrado como dependente de ThemeContext no seu campo dependencies, não de UserContext.

O que costuma confundir nesse assunto

1. Achar que props mudam causam re-renders

Não é assim que funciona. O que causa o re-render é a mudança de estado no componente pai. As props são consequência disso — o pai re-renderiza, produz novos valores e os passa para baixo. O filho re-renderiza porque o pai re-renderizou, não porque as props mudaram.

2. Achar que React.memo verifica o conteúdo de objetos e arrays

Não verifica. O React.memo usa shallow equality com Object.is — comparação de referência para objetos e arrays. Dois objetos com conteúdo idêntico mas referências diferentes são considerados diferentes. Para que o React.memo funcione com objetos, arrays e funções como props, as referências precisam ser estáveis entre renders.

3. Achar que batching sempre existiu para código assíncrono

Antes do React 18, o batching automático só funcionava dentro de event handlers do React. setTimeout, Promises e listeners nativos disparavam um render por setState. O automatic batching do React 18 mudou isso — qualquer contexto agora é batchado por padrão.

4. Achar que a render phase atualiza o DOM

A render phase só calcula — ela chama funções componente e produz uma árvore de elementos. A atualização do DOM acontece na commit phase, em uma fase separada. Confundir as duas leva a confusão sobre quando useLayoutEffect vs useEffect rodam, e por quê ler o DOM durante o render não reflete o estado mais recente.

5. Achar que React.memo protege contra re-renders de contexto

Não protege. React.memo compara props. Context é uma dependência separada, rastreada via o campo dependencies de cada fiber. Se o componente consome um contexto e esse contexto muda, ele vai re-renderizar — React.memo ou não.

6. Usar índice como key em listas

O índice muda quando a ordem da lista muda. O React usa a key para rastrear a identidade de cada item entre renders — se a key muda, o React trata o item como um elemento completamente novo, desmonta o anterior e monta um novo. Com índice como key, reordenar uma lista faz o React desmontar e remontar todos os itens reordenados, perdendo estado interno e gerando trabalho desnecessário.

Fechando a ideia

Se a gente resumir o modelo inteiro, ele fica assim:

Quando esse modelo fica claro, depurar um componente que re-renderiza mais do que deveria deixa de ser tentativa e erro. Você consegue rastrear de onde veio o render, por que ele propagou, e onde colocar uma barreira — se é o caso de colocar.

← voltar para o blog