Pular para o conteúdo
Cursos

DevClub

LógicaFront-endBack-endMobile

IA Club

IA na prática
Estudar programaçãoEstudar IA
ConceitoAvançadocódigo testado

Re-render no React: por que o componente roda de novo

O que dispara uma nova renderização, o que o React faz com o resultado e por que renderizar de novo não é a mesma coisa que mexer no DOM.

Rodolfo Mori15 min de leitura

Renderizar, no React, é executar a função do componente e receber de volta a descrição da tela. Só isso. O React compara essa descrição com a anterior e, se achar diferença, toca no DOM. Rodar de novo é barato; mexer no DOM é que custa — e as duas coisas quase nunca acontecem na mesma proporção.

Todos os exemplos aqui são o mesmo painel: a tela de acompanhamento da Lavanderia Água Viva, com a lista de ordens de serviço, um contador de ciclos concluídos no cabeçalho e um rodapé fixo. Cada número que aparece nesta página saiu de uma execução real no Node 24.16.0, com React 19.2.8 renderizando dentro do jsdom 30.0.1 — o mesmo React que roda no navegador, só que num DOM que eu consigo instrumentar e contar.

Refazer a planta não é reformar a casa

Um arquiteto pode recalcular a planta inteira e descobrir que só uma placa da recepção mudou. Recalcular a planta é trabalho; quebrar parede e mover móvel é outro, bem mais caro. Misturar os dois faz qualquer nova execução da função parecer uma reforma completa.

No React, render é executar componentes para produzir uma nova descrição; commit é aplicar ao DOM apenas as diferenças necessárias. Use os contadores do primeiro experimento como microprática: compare quantas vezes a função roda com quantas alterações reais aparecem no DOM. Os números provam onde o custo aconteceu.

O JSX que você escreve não é HTML e não é o componente. Ele vira uma chamada de função que devolve um objeto simples, com o tipo e as props. Esse objeto é o elemento: uma receita, não o prato.

jsx
import { createRoot } from 'react-dom/client';

const ORDENS = [
  { id: 1, cliente: 'Marina Alves', pecas: 12 },
  { id: 2, cliente: 'Douglas Prado', pecas: 4 },
];

function CardOrdem({ ordem }) {
  console.log('CardOrdem rodou para', ordem.cliente);
  return <li>#{ordem.id} {ordem.cliente}{ordem.pecas} peças</li>;
}

function ListaOrdens({ ordens }) {
  console.log('ListaOrdens rodou');
  return <ul>{ordens.map((o) => <CardOrdem key={o.id} ordem={o} />)}</ul>;
}

const elemento = <ListaOrdens ordens={ORDENS} />;
console.log('o que o JSX virou:', { type: elemento.type.name, props: elemento.props });

createRoot(document.getElementById('root')).render(elemento);
console.log('HTML no navegador:', document.getElementById('root').innerHTML);
o que o JSX virou: { type: 'ListaOrdens', props: { ordens: [ [Object], [Object] ] } } ListaOrdens rodou CardOrdem rodou para Marina Alves CardOrdem rodou para Douglas Prado HTML no navegador: <ul><li>#1 Marina Alves — 12 peças</li><li>#2 Douglas Prado — 4 peças</li></ul>

Repare na ordem. O elemento existiu antes de qualquer console.log de dentro dos componentes: criar o elemento não executa nada. Quem executa é o React, na hora do render, e ele desce de cima para baixo — pai primeiro, filhos depois.

Guarde essa distinção, porque ela sustenta o artigo inteiro:

etapa o que acontece custo
criar o elemento o JSX vira { type, props } um objeto em memória
render o React chama a função do componente executar JavaScript
reconciliação compara o resultado com o da vez anterior comparar objetos
commit aplica no DOM só a diferença tocar na página

As três coisas que disparam um render

Um componente roda de novo em três situações, e só nelas: o estado dele mudou, o pai renderizou, ou um contexto que ele consome mudou.

Este trecho tem uma Bancada com estado de turno, uma Maquina com uma Etiqueta dentro, e um Cesto com estado próprio:

jsx
const TurnoContexto = createContext('manhã');

function Etiqueta() {
  const turno = useContext(TurnoContexto);
  return <span>turno: {turno}</span>;
}

function Maquina({ nome }) {
  return <div><b>{nome}</b><Etiqueta /></div>;
}

function Cesto() {
  const [pecas, setPecas] = useState(0);
  return <p>peças no cesto: {pecas}</p>;
}

function Bancada() {
  const [turno, setTurno] = useState('manhã');
  return (
    <TurnoContexto value={turno}>
      <Maquina nome="Lavadora 1" />
      <Cesto />
    </TurnoContexto>
  );
}

Chamando cada setter de fora, por um roteiro de teste, e anotando quem rodou:

montagem .....................: Bancada, Maquina, Etiqueta, Cesto estado do Cesto mudou ........: Cesto estado da Bancada mudou ......: Bancada, Maquina, Etiqueta, Cesto setPecas(3) com pecas já em 3 : []

Três leituras. O estado do Cesto mudou e só o Cesto rodou: o irmão não sabe de nada, e o pai também não. O estado da Bancada mudou e a árvore inteira abaixo dela rodou. E setPecas(3) com pecas já valendo 3 não disparou render nenhum — o React compara o valor novo com o antigo usando Object.is e desiste antes de começar. É por isso que atualizar estado sem criar valor novo não repinta a tela, assunto de atualizar estado no React sem mutar array e objeto.

O terceiro gatilho é o mais escorregadio, porque ele atravessa qualquer barreira. Aqui a Maquina está envolvida em memo, então o render do pai não chega nela — mas a Etiqueta, lá dentro, consome o contexto:

jsx
const Maquina = memo(function Maquina({ nome }) {
  return <div><b>{nome}</b><Etiqueta /></div>;
});
quem rodou: Bancada, Etiqueta, Cesto HTML final: <div><b>Lavadora 1</b><span>turno: tarde</span></div><p>peças no cesto: 0</p>

A Maquina foi pulada e a Etiqueta, que está dentro dela, rodou assim mesmo. O Cesto rodou pelo gatilho anterior — o pai renderizou. Contexto não desce pela árvore de props: quando o valor do provedor muda, o React percorre a árvore a partir dele procurando quem declarou depender daquele contexto, e essa varredura atravessa o memo em vez de parar nele. Se isso te pegou de surpresa, vale ler Context API no React com esse detalhe em mente.

O que o React compara antes de encostar no DOM

Aqui está o mal-entendido que faz gente otimizar o que não precisa. “O componente renderizou de novo” não quer dizer “a página foi reescrita”.

Montei o painel completo — cabeçalho com contador, ListaOrdens com seis CardOrdem e Rodape — e coloquei um MutationObserver no elemento raiz para contar quantas alterações reais chegaram ao DOM depois de um clique no botão “Registrar ciclo”:

jsx
// as seis ordens do dia; cada função de componente do painel faz execucoes++
const ORDENS = [
  { id: 1, cliente: 'Marina Alves', pecas: 12, status: 'lavando' },
  { id: 2, cliente: 'Douglas Prado', pecas: 4, status: 'secando' },
  { id: 3, cliente: 'Heloísa Bastos', pecas: 9, status: 'pronto' },
  { id: 4, cliente: 'Marcos Tanaka', pecas: 21, status: 'lavando' },
  { id: 5, cliente: 'Cláudia Ferraz', pecas: 6, status: 'pronto' },
  { id: 6, cliente: 'Ubirajara Melo', pecas: 3, status: 'secando' },
];
let execucoes = 0;

const raiz = document.getElementById('root');
createRoot(raiz).render(<Painel />);

const liAntes = [...raiz.querySelectorAll('li')];
execucoes = 0;

const registros = [];
const obs = new MutationObserver((lista) => registros.push(...lista));
obs.observe(raiz, {
  childList: true,
  subtree: true,
  characterData: true,
  characterDataOldValue: true,
  attributes: true,
});

raiz.querySelector('button').click();

obs.disconnect();
console.log('funções de componente executadas:', execucoes);
console.log('mutações no DOM:', registros.length);
for (const r of registros) {
  const pai = r.target.parentNode.nodeName.toLowerCase();
  console.log(`  ${r.type} em <${pai}>: "${r.oldValue}" -> "${r.target.data}"`);
}
const liDepois = [...raiz.querySelectorAll('li')];
console.log(
  'os 6 <li> continuam sendo os mesmos nós?',
  liAntes.every((n, i) => n === liDepois[i]),
);
funções de componente executadas: 9 mutações no DOM: 1 characterData em <strong>: "0" -> "1" os 6 <li> continuam sendo os mesmos nós? true

Nove funções de componente executaram. Um nó de texto mudou na página. Os seis <li> da lista continuam sendo exatamente os mesmos objetos de DOM que existiam antes do clique — nenhum foi destruído, nenhum foi recriado.

O que o React faz entre uma coisa e outra é a reconciliação: ele percorre a árvore nova ao lado da antiga e, para cada posição, pergunta se o tipo é o mesmo. Se for, reaproveita o nó do DOM e só ajusta o que mudou. Se não for, joga fora e monta de novo.

Dá para ver isso acontecendo. O mesmo <input>, na mesma posição, com o envólucro mudando de div para section:

jsx
function FichaOrdem({ trocarTipo }) {
  const [compacto, setCompacto] = useState(false);
  const Envolucro = trocarTipo && compacto ? 'section' : 'div';
  return (
    <Envolucro>
      <strong>Ordem #4 — Marcos Tanaka {compacto ? '(compacta)' : ''}</strong>
      <input placeholder="observação da ordem" />
    </Envolucro>
  );
}
envólucro continua <div>: é o mesmo nó do DOM? true o que o campo guarda agora: "não usar alvejante" a tag que envolve: DIV envólucro vira <section>: é o mesmo nó do DOM? false o que o campo guarda agora: "" a tag que envolve: SECTION

Trocando só a tag do envólucro, o <input> foi destruído e recriado, e a observação que a atendente tinha digitado sumiu. É a mesma regra que faz uma key instável apagar o que o usuário escreveu — o assunto de renderizar listas no React com map e a prop key.

O pai renderiza e o filho vai junto, mesmo sem props novas

Volte ao painel completo. O botão “Registrar ciclo” mexe num número que só o cabeçalho mostra. A lista de ordens não depende dele em nada. Mesmo assim:

jsx
function Painel() {
  const [ciclos, setCiclos] = useState(0);
  return (
    <div>
      <header>
        <strong>Ciclos concluídos hoje: {ciclos}</strong>
        <button onClick={() => setCiclos(ciclos + 1)}>Registrar ciclo</button>
      </header>
      <ListaOrdens ordens={ORDENS} />
      <Rodape />
    </div>
  );
}
--- 1. contador no Painel, nenhum memo --- montagem........: {"Painel":1,"ListaOrdens":1,"CardOrdem":6,"Rodape":1} ao clicar.......: {"Painel":1,"ListaOrdens":1,"CardOrdem":6,"Rodape":1} funções rodadas.: 9 mutações no DOM.: 1

O clique produziu exatamente o mesmo trabalho da montagem inicial: nove execuções. ORDENS é a mesma constante, as props de cada card são idênticas, o Rodape não recebe prop nenhuma — e ainda assim todo mundo rodou.

Isso não é um defeito. É a regra que torna o React previsível: quando um componente renderiza, o React descarta a saída anterior dele inteira e usa a nova. Como a saída nova contém elementos novos para os filhos, cada filho precisa ser executado para produzir a própria saída. Por padrão o React não tenta adivinhar que a saída daria igual.

clique no botão · setCiclos(1) Painel cabeçalho ListaOrdens Rodape #1 #2 #3 #4 #5 #6

Dez caixas na árvore e nove execuções: o cabeçalho é markup dentro do Painel, não um componente com função própria. Numa lista de seis ordens isso é irrelevante. Numa lista de mil, com formatação de moeda em cada linha, deixa de ser.

React.memo: onde a comparação para de subir

React.memo envolve um componente e instala uma pergunta antes da execução: as props são as mesmas da vez passada? Se forem — comparação rasa, campo a campo, com Object.is —, o React reaproveita a saída anterior e não desce mais por ali.

jsx
const CardOrdem = memo(function CardOrdem({ ordem }) {
  return <li>#{ordem.id} {ordem.cliente}{ordem.pecas} peças ({ordem.status})</li>;
});

const ListaOrdens = memo(function ListaOrdens({ ordens }) {
  return <ul>{ordens.map((o) => <CardOrdem key={o.id} ordem={o} />)}</ul>;
});

const Rodape = memo(function Rodape() {
  return <footer>Lavanderia Água Viva — 6 máquinas</footer>;
});
--- 2. contador no Painel, memo em Lista, Card e Rodape --- montagem........: {"Painel":1,"ListaOrdens":1,"CardOrdem":6,"Rodape":1} ao clicar.......: {"Painel":1} funções rodadas.: 1 mutações no DOM.: 1

De nove execuções para uma. Note que o memo em ListaOrdens já resolveu sozinho: como ela foi pulada, os seis cards nem chegaram a ser considerados. O memo não é um filtro em cada componente — é uma barreira que interrompe a descida naquele ponto da árvore.

clique no botão · setCiclos(1) Painel cabeçalho ListaOrdens Rodape #1 #2 #3 #4 #5 #6 memo compara as props e para aqui

O problema é que a barreira tem um vidro fácil de quebrar. Basta uma prop que nasce nova a cada render — um objeto literal, um array recém-filtrado, uma arrow function escrita direto no JSX:

jsx
// CardOrdem é o mesmo de cima, envolvido em memo, agora recebendo aoEntregar

function PainelFurado() {
  const [ciclos, setCiclos] = useState(0);
  return (
    <div>
      <button onClick={() => setCiclos(ciclos + 1)}>ciclos: {ciclos}</button>
      <ul>
        {ORDENS.map((o) => (
          <CardOrdem key={o.id} ordem={o} aoEntregar={(id) => console.log(id)} />
        ))}
      </ul>
    </div>
  );
}

function PainelCorrigido() {
  const [ciclos, setCiclos] = useState(0);
  const aoEntregar = useCallback((id) => console.log(id), []);
  return (
    <div>
      <button onClick={() => setCiclos(ciclos + 1)}>ciclos: {ciclos}</button>
      <ul>
        {ORDENS.map((o) => (
          <CardOrdem key={o.id} ordem={o} aoEntregar={aoEntregar} />
        ))}
      </ul>
    </div>
  );
}
aoEntregar criado no JSX...: {"PainelFurado":1,"CardOrdem":6} aoEntregar com useCallback: {"PainelCorrigido":1}

A arrow é uma função nova em cada execução do pai, e Object.is de duas funções diferentes dá false. O memo está lá, roda a comparação, erra, e renderiza tudo de novo — pagando a comparação de graça. Trocando por um useCallback com lista de dependências vazia, a mesma referência sobrevive e os seis cards são pulados. É esse o papel real do useMemo e useCallback: eles não pulam render, eles alimentam quem pula.

Empurrar o estado para baixo, e o truque do children

Antes de sair espalhando memo, existe uma correção mais barata: colocar o estado onde ele é usado. O contador de ciclos só interessa ao cabeçalho. Então o estado deveria morar num componente do tamanho do cabeçalho:

jsx
function ContadorCiclos() {
  const [ciclos, setCiclos] = useState(0);
  return (
    <header>
      <strong>Ciclos concluídos hoje: {ciclos}</strong>
      <button onClick={() => setCiclos(ciclos + 1)}>Registrar ciclo</button>
    </header>
  );
}

function Painel() {
  return (
    <div>
      <ContadorCiclos />
      <ListaOrdens ordens={ORDENS} />
      <Rodape />
    </div>
  );
}
--- 3. contador no proprio componente, nenhum memo --- montagem........: {"Painel":1,"ContadorCiclos":1,"ListaOrdens":1,"CardOrdem":6,"Rodape":1} ao clicar.......: {"ContadorCiclos":1} funções rodadas.: 1 mutações no DOM.: 1

Uma execução, sem memo nenhum, sem useCallback, sem lista de dependências para manter. O Painel não tem estado, então ele não roda de novo; e como ele não roda, ninguém abaixo dele roda.

Nem sempre dá para descer o estado — às vezes o valor precisa mesmo ficar por cima. Nesse caso existe uma saída que quase ninguém usa: passar o que não depende do estado como children.

jsx
function ProvedorCiclos({ children }) {
  const [ciclos, setCiclos] = useState(0);
  return (
    <div>
      <header>
        <strong>Ciclos concluídos hoje: {ciclos}</strong>
        <button onClick={() => setCiclos((c) => c + 1)}>Registrar ciclo</button>
      </header>
      {children}
    </div>
  );
}

function Painel() {
  return (
    <ProvedorCiclos>
      <ListaOrdens ordens={ORDENS} />
      <Rodape />
    </ProvedorCiclos>
  );
}
montagem ..: {"Painel":1,"ProvedorCiclos":1,"ListaOrdens":1,"CardOrdem":6,"Rodape":1} ao clicar .: {"ProvedorCiclos":1} mutações no DOM: 1

De novo: uma execução, zero memo. O motivo é bonito. Os elementos de <ListaOrdens /> e <Rodape /> foram criados dentro do Painel, que não renderizou. Quando o ProvedorCiclos roda, ele devolve o mesmo objeto de children que já tinha; o React vê elemento idêntico na mesma posição e pula. Essa é a base da composição que children e composição no React explora.

Os quatro arranjos do mesmo painel, medidos com o mesmo clique no mesmo botão:

arranjo funções que rodaram mutações no DOM custo em código
estado no Painel, sem memo 9 1 nenhum
estado no Painel, memo nos três filhos 1 1 três memo e cuidado com props
estado no ContadorCiclos 1 1 nenhum
estado no provedor, resto como children 1 1 uma indireção

A leitura dessa tabela é a opinião central deste artigo: memo é a terceira opção, não a primeira. Mover estado e compor com children chegam ao mesmo resultado sem criar uma regra que alguém vai quebrar daqui a três sprints ao adicionar uma prop nova.

Contando renders sem instalar plugin nenhum

Antes de otimizar, meça. Não precisa de extensão: um useRef que incrementa a cada execução já entrega a contagem, porque useRef sobrevive entre renders sem disparar render.

jsx
function useContarRenders(nome) {
  const renders = useRef(0);
  renders.current += 1;
  console.log(`${nome} rodou ${renders.current}x`);
  return renders.current;
}

function CardOrdem({ ordem }) {
  useContarRenders(`CardOrdem #${ordem.id}`);
  return <li>{ordem.cliente}</li>;
}

function Painel() {
  useContarRenders('Painel');
  const [ciclos, setCiclos] = useState(0);
  return (
    <div>
      <button onClick={() => setCiclos(ciclos + 1)}>ciclos: {ciclos}</button>
      <ul>{ORDENS.slice(0, 2).map((o) => <CardOrdem key={o.id} ordem={o} />)}</ul>
    </div>
  );
}
### sem StrictMode — montagem Painel rodou 1x CardOrdem #1 rodou 1x CardOrdem #2 rodou 1x ### sem StrictMode — um clique no botão Painel rodou 2x CardOrdem #1 rodou 2x CardOrdem #2 rodou 2x

Agora o detalhe que faz muita gente diagnosticar errado. O mesmo código, dentro de <StrictMode> — que o Vite coloca por padrão no main.jsx:

### dentro de <StrictMode> — montagem Painel rodou 1x Painel rodou 2x CardOrdem #1 rodou 1x CardOrdem #1 rodou 2x CardOrdem #2 rodou 1x CardOrdem #2 rodou 2x ### dentro de <StrictMode> — um clique no botão Painel rodou 3x Painel rodou 4x CardOrdem #1 rodou 3x CardOrdem #1 rodou 4x CardOrdem #2 rodou 3x CardOrdem #2 rodou 4x

Cada número dobrou. Isso é o StrictMode executando a função do componente duas vezes de propósito, no desenvolvimento, para revelar quem faz efeito colateral durante a renderização. Não acontece em produção. Ao contar renders, divida por dois — ou compare cenários entre si, que é o que importa.

Renderização em série e sem parar é outra história: quando a contagem sobe sozinha até o navegador travar, o problema é setState chamado durante o render, e o React avisa com Maximum update depth exceeded.

Onde o tempo realmente vai, medido com o Profiler

Contagem de execuções é útil, mas não é tempo. Para tempo, o React expõe o componente <Profiler>, que entrega a duração real de cada commit.

jsx
const STATUS = ['lavando', 'secando', 'passando', 'pronto'];

const fazerOrdens = (n) =>
  Array.from({ length: n }, (_, i) => ({
    id: i + 1,
    cliente: `Cliente ${i + 1}`,
    pecas: (i % 17) + 1,
    status: STATUS[i % 4],
    valor: 8.5 * ((i % 17) + 1),
  }));

function CardOrdem({ ordem }) {
  const valor = ordem.valor.toLocaleString('pt-BR', {
    style: 'currency',
    currency: 'BRL',
  });
  return <li>#{ordem.id} {ordem.cliente}{ordem.pecas} peças · {valor} ({ordem.status})</li>;
}
const CardOrdemMemo = memo(CardOrdem);

function Painel({ ordens, Card }) {
  const [ciclos, setCiclos] = useState(0);
  return (
    <div>
      <button onClick={() => setCiclos((c) => c + 1)}>ciclos: {ciclos}</button>
      <ul>{ordens.map((o) => <Card key={o.id} ordem={o} />)}</ul>
    </div>
  );
}

async function medir(elemento, cliques = 25) {
  const raiz = document.createElement('div');
  document.body.appendChild(raiz);
  const duracoes = [];
  const root = createRoot(raiz);
  await act(() =>
    root.render(
      <Profiler
        id="painel"
        onRender={(id, fase, duracao) => {
          if (fase === 'update') duracoes.push(duracao);
        }}
      >
        {elemento}
      </Profiler>,
    ),
  );

  const botao = raiz.querySelector('button');
  for (let i = 0; i < cliques; i++) await act(() => botao.click());
  await act(() => root.unmount());

  const amostras = duracoes.slice(5); // os 5 primeiros são aquecimento
  return amostras.reduce((s, n) => s + n, 0) / amostras.length;
}

for (const n of [6, 50, 400, 2000]) {
  const ordens = fazerOrdens(n);
  const sem = await medir(<Painel ordens={ordens} Card={CardOrdem} />);
  const com = await medir(<Painel ordens={ordens} Card={CardOrdemMemo} />);
  console.log(
    `${String(n).padStart(4)} cards | sem memo ${sem.toFixed(2).padStart(6)} ms` +
      ` | com memo ${com.toFixed(2).padStart(6)} ms | diferença ${(sem - com).toFixed(2)} ms`,
  );
}

O act do React é o que garante que cada clique termine de renderizar antes do próximo. Tirando ele daí, os 25 cliques só enfileiram trabalho e o laço acaba antes de qualquer commit: o <Profiler> registra zero atualizações e a média sai NaN. Rodei o mesmo painel com quatro tamanhos de lista, 25 cliques em cada, sempre descartando os 5 primeiros como aquecimento, e comparando com e sem memo no card:

6 cards | sem memo 0.23 ms | com memo 0.06 ms | diferença 0.17 ms 50 cards | sem memo 1.51 ms | com memo 0.31 ms | diferença 1.20 ms 400 cards | sem memo 7.70 ms | com memo 0.39 ms | diferença 7.31 ms 2000 cards | sem memo 39.86 ms | com memo 2.59 ms | diferença 37.27 ms

Repeti a bateria quatro vezes. A coluna “sem memo” ficou entre 0,22 e 0,27 ms nos 6 cards, entre 1,44 e 1,57 ms nos 50, entre 7,46 e 7,70 ms nos 400 e entre 38,9 e 39,9 ms nos 2000: variação de ruído, sem nenhuma linha trocando de lugar. Ambiente: MacBook, Node 24.16.0, React 19.2.8 em modo de desenvolvimento dentro do jsdom. No navegador os valores absolutos mudam, e o commit real ainda soma layout e pintura por cima; a proporção entre as linhas é o que se aproveita.

O que a tabela diz é simples e é quase sempre ignorado: o ganho do memo cresce com o tamanho da lista, e não existe em lista pequena. Economizar 0,17 ms num painel de seis ordens não muda nada — 16 ms é o orçamento de um quadro a 60 quadros por segundo, e você gastou 1% disso.

O que não vale a pena otimizar

Falta a conta que quase ninguém faz: quanto custa um memo que nunca acerta. Montei 2000 cards recebendo estilo={{ margin: 0 }} — objeto literal, novo a cada render, comparação condenada a falhar:

jsx
const ORDENS_2000 = fazerOrdens(2000);

function CardComEstilo({ ordem, estilo }) {
  return <li style={estilo}>#{ordem.id} {ordem.cliente}{ordem.pecas} peças</li>;
}
const CardComEstiloMemo = memo(CardComEstilo);

function PainelComEstilo({ Card }) {
  const [ciclos, setCiclos] = useState(0);
  return (
    <div>
      <button onClick={() => setCiclos((c) => c + 1)}>ciclos: {ciclos}</button>
      <ul>
        {ORDENS_2000.map((o) => (
          // objeto novo a cada render: a comparação do memo nunca dá igual
          <Card key={o.id} ordem={o} estilo={{ margin: 0 }} />
        ))}
      </ul>
    </div>
  );
}

const mostrar = (nome, media) =>
  console.log(`${nome.padEnd(30)} média ${media.toFixed(2)} ms`);

const sem = await medir(<PainelComEstilo Card={CardComEstilo} />);
mostrar('sem memo', sem);
const com = await medir(<PainelComEstilo Card={CardComEstiloMemo} />);
mostrar('com memo (que nunca acerta)', com);
console.log(`\ndiferença: ${(com - sem).toFixed(2)} ms a mais com memo`);
sem memo média 8.27 ms com memo (que nunca acerta) média 9.98 ms

diferença: 1.71 ms a mais com memo

Rodei essa comparação oito vezes seguidas. O memo inútil perdeu nas oito, com diferença entre 0,34 ms e 2,64 ms — de 4% a 34% a mais do que não ter memo nenhum. Faz sentido: você paga a comparação de todas as props de todos os cards e renderiza tudo do mesmo jeito. Mas repare na ordem de grandeza, que já diz o essencial: o memo inútil é sempre um prejuízo, e mesmo assim um prejuízo de poucos milissegundos.

O custo grande é o que não aparece no cronômetro. Uma dependência a mais para manter em cada useCallback. Um memo que dá falsa segurança na revisão de código. Um componente que fica difícil de alterar porque qualquer prop nova pode furar a barreira em silêncio, sem erro, sem aviso — só a tela ficando lenta alguns meses depois.

O critério medido neste laboratório:

situação o que fazer
lista com menos de ~50 itens leves nada; renderizar de novo é mais barato que a conversa sobre isso
estado que só um pedaço da tela usa mover o estado para esse pedaço
estado que precisa ficar em cima passar o resto como children
lista grande com trabalho por item memo no item, e useCallback em toda função que virar prop
cálculo caro dentro do componente useMemo no cálculo, antes de pensar em memo no componente
digitação travando o formulário isolar o campo no próprio componente antes de qualquer outra coisa

Duas armadilhas fecham a lista. memo num componente que recebe children é quase sempre inútil, pelo motivo já visto. E memo num componente cujo pai renderiza a cada tecla digitada, passando um array filtrado na hora, também é: o array é novo, a comparação falha, e você só adicionou código.

O que vem depois

O caminho prático é este: primeiro descubra onde o estado está morando, com o contador de useRef desta página. Se ele estiver acima de quem realmente precisa dele, desça — muitos problemas de render desaparecem quando o estado volta para o menor dono possível. Só depois, com o <Profiler> mostrando milissegundos de verdade, entre com memo e useCallback no ponto exato.

Para revisar o que sustenta tudo isso, comece por useState no React, que é o gatilho de todo re-render, e depois volte ao guia completo de React para ver onde performance entra na ordem de estudo.

  • react
  • re-render
  • performance
  • reconciliacao
  • memo

Perguntas frequentes

Re-render sempre deixa a tela lenta?
Não. Renderizar é executar uma função e comparar objetos em memória. Numa lista de 6 itens isso custou 0,23 ms na medição deste artigo — nada. O problema aparece quando a mesma função roda centenas de vezes por digitação, ou quando cada execução faz trabalho pesado, como formatar moeda, ordenar array grande ou montar gráfico.
O StrictMode dobra os renders também em produção?
Não. A dupla execução só acontece no build de desenvolvimento e existe para revelar componentes que dependem de efeito colateral durante a renderização. No build de produção cada componente roda uma vez por render.
useMemo e useCallback impedem o componente de renderizar?
Não impedem. Eles só mantêm a mesma referência de um valor ou de uma função entre renders. Quem pula a execução do componente filho é o React.memo — o useCallback existe para que a comparação do memo tenha chance de dar igual.
E o React Compiler, resolve isso sozinho?
Ele memoriza automaticamente o que hoje você escreve à mão com memo, useMemo e useCallback. Continua valendo entender o mecanismo: o compilador reduz o trabalho repetido, mas não move estado de lugar nem conserta um componente que faz cálculo caro dentro do corpo.
Dá para confiar em medição feita no Node com jsdom?
Para contagem de execuções, sim: quem decide o que roda é o React, não o navegador. Já os tempos em milissegundos são específicos desta máquina e deste ambiente. No navegador os números absolutos mudam; a ordem de grandeza entre os cenários é que interessa.

Dúvidas e comentários

Travou em algum passo? Pergunte aqui — a equipe e outros alunos respondem.

Todo o código deste artigo foi executado em Node 24.16.0 (React 19.2.8, jsdom 30.0.1), e as saídas exibidas são as reais — como produzimos este conteúdo.

Fontes consultadas

  1. React — Render and Commit — react.dev
  2. React — Preserving and Resetting State — react.dev
  3. React — memo — react.dev
  4. React — Profiler — react.dev

Continue por aqui