This is a translation of the original post The Uphill Battle of Memoization by TkDodo
Já existem muitos conteúdos excelentes por aí sobre o que você poderia fazer antes de usar React.memo. O post do Dan Abramov chamado "before you memo" ("Antes de você memoizar" em tradução livre) e o "Simples truque para otimizar re-renderizações no React" do Kent C. Dodds são dois ótimos exemplos de leitura sobre o tema.
A ideia é deixar a composição do componente resolver o problema para você, seja ao mover o state (Estado da Aplicação) para baixo ou movendo o conteúdo para cima. Isso é brilhante porque a composição de componentes é o modelo mental natural do React. Como o Dan mostrou, isso também vai funcionar bem com Server Components, que agora já são realidade.
O que está faltando na a maior parte dos artigos que eu li é o porquê. Sim, a composição é ótima, mas qual o problema ao usar React.memo
? Por que não é tão bom quanto a primeira opção?
Então aqui vai a minha opinião sobre isso:
"Memoização" é muito fácil de quebrar
Para recapitular: Quando o React renderiza uma árvore de componentes, vai fazê-lo de cima para baixo (top-down), para todos os componentes "filhos". Uma vez que uma renderização começa, não existe maneira de fazermos ela parar. No geral, isso é uma coisa boa, por que renderizações nos fazem ter certeza de que nós estamos vendo a versão correta e mais atual do nosso estado na tela. Além disso renderizações em geral são rápidas.
E aí existem aqueles edge cases (casos de rara ocorrência) onde elas não são. Todos nós temos aqueles componentes que não renderizam tão rápido quanto gostaríamos e que por alguma razão nós não podemos mudar. Para nossa sorte, o React ainda pode "abortar" renderizações e vai fazê-lo se enxergar "a mesma coisa", isto é, se nada mudou (props ou estado). Isso é o que faz técnicas como mover o conteúdo para componentes acima funcionarem:
function App() {
return (
<ColorPicker>
<p>Hello, world!</p> {/* Conteúdo movido */}
<ExpensiveTree /> {/* Conteúdo movido */}
</ColorPicker>
)
}
function ColorPicker({ children }) {
const [color, setColor] = React.useState('red')
return (
<div style={{ color }}>
<input
value={color}
onChange={(event) => setColor(event.target.value)}
/>
{children} {/* Estava aqui anteriormente */}
</div>
)
}
Se os "filhos" (children) possuem exatamente a mesma referência, o React pode fazer um curto-circuito na renderização. Mesmo que Color
mude, o componente ExpensiveTree
não vai re-renderizar por isso.
A solução alternativa seria continuar renderizando tudo dentro do mesmo componente, mas adicionar o React.memo
ao redor do componente ExpensiveTree
.
function App() {
const [color, setColor] = useState('red')
return (
<div style={{ color }}>
<input
value={color}
onChange={(event) => setColor(event.target.value)}
/>
<p>Hello, world!</p>
<ExpensiveTree /> {/* Chamar o componente normalmente */}
</div>
)
}
function ExpensiveComponent() {
return <div>I'm expensive!</div>
}
const ExpensiveTree = React.memo(ExpensiveComponent) // Uso do React.memo
Se adicionarmos um React.memo
ao redor do componente, o React vai pular a renderização deste componente (e todos os seus componentes-filho) se as suas props não mudaram. Isso certamente vai alcançar o mesmo resultado de quando mudarmos a composição do componente, mas será bem mais fácil de quebrar no futuro.
Quando um componente é "memoizado", o React vai comparar cada prop com Object.is. Se as props não mudaram, a re-renderização será abortada/pulada. Isso funciona perfeitamente no nosso exemplo atual, por que nosso componente atualmente não recebe nenhuma prop. Também vai funcionar com valores primitivos como props, mas não tão bem com funções, objetos e arrays.
Vamos fazer uma alteração aparentemente inofenciva no nosso ExpensiveComponent
- adicionando estilos com a prop style
.
function ExpensiveComponent({ style }) {
return <div style={style}>I'm expensive!</div>
}
const ExpensiveTree = React.memo(ExpensiveComponent)
Isso é como os componentes evoluem geralmente ao longo do tempo - props são adicionadas. O ponto é: os componentes que invocam o componente ExpensiveTree
não necessariamente sabem que ele está "memoizado". Afinal, isso é apenas uma otimização de performance e um detalhe de implementação.
Se nós adicionarmos uma prop style
inline ao renderizar nosso componente ExpensiveTree
:
<ExpensiveTree style={{ backgroundColor: 'blue' }} />
Nós, inadvertidamente, arruinamos nossa "memoização". porque a prop style
será um novo objeto a cada nova renderização. Para o React, parece que as props mudaram, então não é possível pular a renderização.
Ok, certo, nós podemos consertar isso ao usar React.useMemo
no objeto de estilo:
function App() {
// "memoizando" o objeto de estilo
const memoizedStyle = React.useMemo(
() => ({ backgroundColor: 'blue' }),
[]
)
return <ExpensiveTree style={memoizedStyle} />
}
Isso é possível no nosso exemplo simplificado, mas imagine como nosso código iria parecer se nós tivéssemos mais props que precisam ser "memoizadas". Isso faria nosso código parecer mais difícil de analisar, e não existem garantias de que os componentes que usam este componente iriam realmente fazer essa "memoização".
Fica ainda mais difícil quando o estilo em si é passado como uma prop para o componente que renderiza o ExpensiveTree
:
function App({ style }) {
const memoizedStyle = React.useMemo(() => style, [style])
return <ExpensiveTree style={memoizedStyle} />
}
Essa "memoização" não traz benefício algum. Não sabemos se o estilo será passado como um objeto inline para o App
, então memoizá-lo aqui não faz sentido. Seria necessário criar uma referência estável no ponto onde o App
é chamado.
{children}
O que é pior - Esta não é a única forma de como nossa melhora de performacne pode quebrar. Outro "A-há!" é que componentes "memoizados" não vão funcionar como se espera se eles aceitam a prop children
.
function App() {
return (
<ExpensiveTree>
<p>Hello, world!</p> {/* Parágrafo passado como children */}
</ExpensiveTree>
)
}
function ExpensiveComponent({ children }) {
return (
<div>
I'm expensive!
{children} {/* children */}
</div>
)
}
const ExpensiveTree = React.memo(ExpensiveComponent)
Ufa! Preciso admitir - Por muito tempo eu não sabia que isso quebraria a memoização. Afinal, por que quebraria? Eu estou sempre passando a mesma e estável tag <p>
como children, certo? bom... não na verdade. O JSX é apenas uma sintaxe simplificada para a função React.createElement
, que vai criar um novo objeto a cada nova renderização. Então, mesmo que a tag <p>
pareça a mesma para nós, ela não terá a mesma referência na memória.
A gente até pode envolver os componentes filhos que estamos passando para o componente "memoizado" com useMemo
também, mas espero que você já esteja notando que estamos travando uma batalha quase impossível de vencer. O próximo programador pode simplesmente vir e passar um objeto ou array vazio como valor de fallback para uma prop do nosso componente memoizado — e aí voltamos à estaca zero.
A Alternativa
Usar o React.memo
pode ser como andar em um campo minado, e escolher uma das alternativas propostas me parece uma escolha melhor para mim. Mas às vezes, aparentemente não podemos evitar memoizar um componente. Vamos dar uma olhada em um exemplo que vi no X (ex twitter) que me deu a ideia para criar este blog post:
Eu raramente preciso adicionar otimizações de performance no React.
— Cory House (@housecor) September 28, 2023
Mas nós temos uma página com 5 tabelas grandes e uma barra de resumo. Quando uma tabela muda, tudo renderiza novamente. É lento.
Solução:
1. Envolvi cada tabela em um memo.
2. Envolvi as funções que passei para baixo em um useCallback.
MUITO mais rápido.
Eu esperaria que a árvore de componentes parecesse algo como isto (Estou usando duas tabelas ao invés de cinco por brevidade):
function App() {
const [state, setState] = React.useState({
table1Data: [],
table2Data: [],
})
return (
<div>
<Table1 data={state.table1Data} />
<Table2 data={state.table2Data} />
<SummaryBar
data={calculateSummary(state.table1Data, state.table2Data)}
/>
</div>
)
}
state
possui os dados de ambas as tabelas, e o SummaryBar
precisa de acess a todos esses dados. Nós não podemos mover o estado para baixo (para dentro das tabelas), e nós também não podemos compor os componentes de uma forma diferente. Parece que a memoização é a nossa única opção.
Não comece renderizando
Lembra de quando eu disse que uma vez uma renderização começa nós não temos nenhuma forma de pará-la? Isso ainda é verdade, mas e se nós parássemos a renderização no começo...? 🤔
Se o estado da aplicação
(state) não estivesse no topo do componente App
, nós não precisaríamos re-renderizar toda a arvore quando o estado mudasse. Mas onde nós o colocaríamos então? Nós já estabelecemos que nós não podemos movê-lo para baixo (para o componente filho) - Então vamos colocar o estado ao lado: fora do React.
Isso é precisamente o que a maioria das soluções de gerenciamento de estado fazem. Elas guardam o estado da aplicação fora do React e cirurgicamente disparam re-renderizações de partes da árvore de componentes que precisam saber das mudanças no estado. Se você já utilizou o React Query antes, isso é exatamente o que está acontecendo lá também. Sem essa técnica, você veria muito mais re-renderizações do que gostaria.
Então sim, minha solução alternativa proposta é trazer um gerenciamento de estado efetivo. Eu vou usar Zustand porque é o que mais estou familiarizado a utilizar.
const useTableStore = create((set) => ({
table1Data: [],
table2Data: [],
actions: {...}
}))
export const useTable1Data = () =>
useTableStore((state) => state.table1Data)
export const useTable2Data = () =>
useTableStore((state) => state.table2Data)
export const useSummaryData = () =>
useTableStore((state) =>
calculateSummary(state.table1Data, state.table2Data)
)
Agora todo componente pode se inscrever internamente para o estado que mais lhe interessa, evitando qualquer renderização de cima para baixo (top-down). Se table2Data
atualizar, Table1
não vai ser renderizado novamente. Esta é tão efetivo quanto memoizar as tabelas, mas não vai sofrer com pegadinhas geradas ao adicionar novas props que podem impactar negativamente a performance.
Uma saída
Garantido, todas as soluções presentes aqui não são perfeitas. Memoização em geral faz o nosso código ser mais difícil de ler, e mais fácil de implementar errado (o que torna a pior opção para mim). Usar gerenciadores de estados externos é um pouco melhor - você pode ter essa dependencia na sua aplicação de qualquer forma. Adaptando a forma que você compõe componentes ainda é a melhor opção, mas nem sempre é possível.
O que realmente poderia ser uma saída seria se nós mudássemos as regras do jogo. Records e Tuples são propostas ECMAScript que estão em estágio 2 há algum tempo, nos ajudaria com Arrays e objetos, mas não com funções. Sebastien Lorber tem um ótimo artigo sobre isso.
O time do React também tem dado dicas de que eles estão trabalhando em um compilador chamado "React Forget", que supostamente vai memoizar tudo para nós automaticamente. Dito isto, nós poderíamos ter otimizações de performance do React.memo
sem margem para erro.
Top comments (0)