A maioria dos tutoriais de performance do React termina com o mesmo conselho ruim: envolva tudo em useMemo e useCallback e considere o trabalho feito. Se você seguiu esse conselho, provavelmente tornou sua aplicação mais lenta. Esses hooks não são gratuitos. Cada um aloca memória, compara dependências e armazena valores em cache. Usados sem propósito, eles se tornam um overhead em vez de uma otimização.

Vamos simplificar isso e focar no que realmente importa.

O que cada hook realmente faz

useMemo lembra um valor. Você passa para ele uma função que realiza um trabalho pesado, e ele retorna o resultado. No próximo render, se suas dependências não tiverem mudado, o React pula o cálculo e retorna o resultado antigo.

useCallback lembra uma função. Ele não executa a função para você. Ele simplesmente retorna a mesma instância da função entre os renders, desde que suas dependências permaneçam as mesmas.

Essa é toda a diferença. Um faz o cache de um valor computado. O outro faz o cache de uma referência. Confundir os dois leva a um código que parece otimizado, mas se comporta de forma idêntica ao código sem o uso de hooks, enquanto consome memória extra.

Por que a identidade da função quebra sua árvore

Quando um componente sofre re-render, o React executa todo o corpo da função novamente. Cada variável é recriada. Cada função inline recebe um endereço de memória totalmente novo.

Em JavaScript, duas funções que contêm exatamente a mesma lógica não são iguais. () => {} === () => {} resulta em false. A mesma regra se aplica a objetos e arrays. Se o seu componente pai define handleSubmit e o passa para um filho, esse filho recebe uma nova prop em cada renderização. Mesmo que o filho esteja envolvido em React.memo, ele não consegue identificar que a nova função faz a mesma coisa que a antiga. A referência mudou, então o filho sofre re-render.

Este é o problema fundamental que o useCallback foi criado para resolver. Não se trata de velocidade. Trata-se de estabilidade.

Quando o useMemo vale a pena

Você precisa de useMemo quando está realizando um trabalho que é objetivamente caro e você consegue perceber um atraso mensurável.

Pense em filtrar um conjunto de dados massivo. Se você tem uma tabela com dezenas de milhares de linhas e um campo de busca, você pode escrever algo assim dentro do seu componente:

const visibleRows = rows.filter(r => r.name.includes(query));

Sem o useMemo, esse loop é executado em cada render. Se o usuário clicar em um botão que alterna uma barra lateral, o pai sofre re-render, e seu filtro é executado novamente, mesmo que rows e query nunca tenham mudado. Em um conjunto de dados grande, esse engasgo é visível.

O useMemo resolve isso fixando o resultado:

const visibleRows = useMemo(() => {
  return rows.filter(r => r.name.includes(query));
}, [rows, query]);

Agora o React só executa esse filtro novamente quando as dependências realmente mudarem.

A mesma lógica se aplica a cálculos matemáticos complexos, transformação de respostas de API em formatos amigáveis para gráficos ou derivação de estado que, de outra forma, seria recalculado constantemente.

Existe um segundo caso de uso, menos óbvio. Se você criar um objeto ou array localmente e o incluir em um array de dependências do useEffect, você pode acidentalmente disparar esse efeito em cada render. Objetos e arrays inline recebem novas identidades a cada vez, então o efeito percebe uma dependência alterada e é disparado novamente. Memorizar esse objeto com useMemo mantém a referência estável e permite que seu efeito seja executado apenas quando os dados subjacentes realmente mudarem.

Quando o useCallback se torna necessário

O useCallback é mais importante quando você está passando handlers para componentes filhos que foram otimizados com React.memo.

Imagine um componente pai que contém um contador. Ele também renderiza uma lista de filhos custosa:

function Parent() {
  const [count, setCount] = useState(0);
  
  const handleItemClick = (id) => {
    console.log(id);
  };
  
  return (
    <div>
      <button onClick={() => setCount(c + 1)}>{count}</button>
      <ExpensiveList onItemClick={handleItemClick} />
    </div>
  );
}

Toda vez que count muda, o Parent sofre re-render. Um novo handleItemClick é criado. Como o ExpensiveList recebe uma nova referência de prop, ele também sofre re-render. Se o ExpensiveList estiver envolvido em React.memo, essa memorização é completamente desperdiçada porque a prop da função mudou.

O useCallback preserva a referência:

const handleItemClick = useCallback((id) => {
  console.log(id);
}, []);

Agora o ExpensiveList só sofre re-render quando realmente precisa.

Outra situação crítica envolve o useEffect. Se um efeito se inscreve em uma função definida dentro do seu componente, e essa função muda de identidade a cada render, o efeito será destruído e reinscrito repetidamente. Memorizar a função mantém o efeito estável.

A armadilha do array de dependências e stale closures

Ambos os hooks dependem de arrays de dependências, e é aqui que a maioria dos bugs se esconde.

Se você omitir uma variável do array de dependências, sua função ou valor memorizado fará um closure sobre uma versão antiga dessa variável. Isso é um stale closure. A interface do usuário pode exibir dados atualizados, mas seu callback ainda estará olhando para o estado de três renderizações atrás. A correção é simples, mas fácil de passar despercebida durante revisões de código: inclua todos os valores usados dentro do hook que possam mudar.

Execute a regra ESLint react-hooks/exhaustive-deps. Ela detectará omissões óbvias. Mas não a trate como um robô. Entenda por que cada dependência é importante.

O Imposto Oculto da Sobre-Otimização

Iniciantes costumam "blindar" cada função e cada valor com esses hooks porque isso traz uma sensação de segurança. Esse hábito acaba sendo contraproducente.

O React precisa armazenar valores em cache na memória. A cada renderização, ele deve iterar pelo seu array de dependências e comparar cada item usando Object.is. Essa comparação é barata, mas não é gratuita. Se você envolver um manipulador de evento trivial como onClick={() => setOpen(true)} dentro de um useCallback, você estará pagando custos de memória e CPU para evitar a criação de uma função que seria instantânea de alocar.

Os hooks também adicionam ruído. O código envolvido em useMemo e useCallback é mais difícil de ler e mais difícil de manter. Cada array de dependências é um potencial stale closure esperando para te pegar.

A verdadeira regra de ouro é pouco glamorosa, mas eficaz: escreva código simples primeiro. Otimize apenas quando tiver provas de um problema. Use o React DevTools Profiler para identificar quais componentes são custosos e quais renderizações são desperdiçadas. Se uma renderização levar menos de alguns milissegundos, nenhum usuário notará, e sua memorização não estará resolvendo nada.

Em Resumo

useMemo é para valores custosos. useCallback é para referências de funções estáveis. Nenhum dos hooks faz seu componente renderizar mais rápido por si só; eles evitam trabalhos desnecessários subsequentes. Comece sem eles, meça com ferramentas reais e adicione-os precisamente onde o profiler mostrar um gargalo. Um código limpo que renderiza novamente de vez em quando quase sempre vencerá um código com excesso de engenharia que memoriza tudo.