Renderização Declarativa - React Reconciler

Introdução
Eu fiz esse post aqui como forma de eu aprofundar no tema de renderização declarativa, minha ideia é abordar alguams bibliotecas que funcionam dessa forma e entender com mais profundidade as diferenças de abordagens entre cada ferramenta.
Eu quero cobrir tanto o React Reconciler quanto o Jetpack Compose Runtime, e talvez outras ferramentas que eu for encontrando no caminho. Por hora vamos começar com o React Reconciler.
Disclaimer: Nesse artigo vou procurar colocar referencias das informações que eu estou compartilhando, mas é importante ressaltar que essas informações podem mudar com o tempo ou até mesmo eu posso cometer algum erro, portanto minha recomendação é ler este artigo com o ‘livro de regras embaixo do braço’ e sempre verificar a documentação oficial das ferramentas.
React
É uma biblioteca bem conhecida na web, e eu particularmente já usei bastante em projetos usando react-native. Poderíamos falar especificamente do react native, mas como a ideia é falar sobre a renderização declarativa, vou abordar apenas o react.
Eu imagino que você já deve ter ouvido falar algo sobre ou talvez até desenvolvido alguma aplicação que utilize react, se não teve contato com a ferramenta não tem problema, vamos aprofundando aos poucos. Para iniciar um projeto react é bem simples, basta criar um elemento “root” e chamar uma função passando um componente para ser renderizado:
import { createRoot } from 'react-dom/client'; //react-dom@19
import App from './App';
const domNode = document.getElementById('root');
const root = createRoot(domNode);
root.render(<App />);
Vamos explorar um pouco como o react-dom funciona por baixo dos panos, e como que só esse trecho de código ja permite que voce renderize e atualize a interface do usuário de forma declarativa.
Procurando no código fonte do react-dom, podemos encontrar a função createRoot aqui. esse é um código exemplo de como ele funciona:
type RootType {
render: (element: ReactElement) => void;
// ...
}
export function createRoot(
container: Element | Document | DocumentFragment,
options?: CreateRootOptions,
): RootType {
/*
usa-se o options para fazer uma serie de configurações que nesse momento não são importantes
*/
// react-reconciler/src/ReactFiberReconciler
const root = createContainer(
container,
/* ...demais configurações... */
);
// ...
return new ReactDOMRoot(root);
}
Perceba que o createRoot faz algumas configurações que no momento não são relevantes, importante é que ele chama a função createContainer, que vem de uma biblioteca chamada react-reconciler. Ou seja, a lib react-dom sozinha não cuida da renderização dos elementos, ela delega essa responsabilidade para a biblioteca react-reconciler, o que faz sentido pois ter essa logica abstraida em uma lib separada de react-dom permite que o reconciliador possa ser importado e utilizado de forma independente.

Reconciler & Renderer
O react reconciler é responsavel em fazer a gestão das mudanças de estado e props dos componentes, basicamente ele é o motor que faz toda a lógica de comparação entre uma versão D e D+1 da árvore de componentes, e decide quais componentes precisam ser atualizados, removidos ou adicionados.
Além dessa gestão de mudanças sendo feita, existe outro componente que cuida de como essas mudanças são aplicadas na interface do usuário, e é ai que entra o renderer, ele é a camada que faz a ponte entre o reconciler e a plataforma onde a aplicação está rodando, seja ela web, mobile ou desktop.
Por conta dessa arquitetura desacoplada, que permite que sejam criados vários renderers, APIs que se conectam com o react-reconciler e fazem a implementação na sua plataforma especifica, uma imagem bacana sobre isso:

Algoritmo
Agora sabemos que ele é quem faz todo o trabalho pesado de comparar e decidir o que precisa ser atualizado, varrendo toda a arvore de componentes e fazendo essas comparações.
Em uma perpectiva de performance, quanto menos nós sendo atualizados desnecessáriamente, melhor. Essa comparação e identificação é a chave para uma renderização eficiente.
Links para entender mais sobre o algoritmo de reconciliação:
- Inside Fiber: in-depth overview of the new reconciliation algorithm in React
- The how and why on React’s usage of linked list in Fiber to walk the component’s tree
- source code: react-reconciler

Essas informações salvas dentro desse nó são importantes para que o Reconciler saiba qual nó ele precisa atualizar, e qual ele pode simplesmente ignorar pois não houve mudanças.
Stack Reconciler e Fiber Reconciler
Na versão 15 e anteriores, o react utilizava um algoritmo de reconciliação chamado de “Stack Reconciler”, a desvantagem dele era a performance: ele percorria toda a arvore de componentes de forma sincrona, componente a componente fazendo aplicando as mudanças, isso é passivo a problemas de performance como drop de frames em animações.
Por mais que isso fosse prejudicial, era possivel contornar esse problema de performance fazendo esse trabalho no tempo livre no final dos frames, fazendo uso do requestIdleCallback. Essa saída ajuda mas não é bala de prata porque nós ainda teriamos que percorrer a arvore toda de qualquer forma.
Para resolver de forma definitiva esse problema de performance, foi necessário permitir que o algoritmo pudesse pausar e continuar a execução de um update, além disso esse novo algoritmo introduziu formas de aplicar atualizações com base na prioridade (link para ler mais sobre).

Esse esquema de pausar e continuar os trabalhos, permite que o react quebre todo o trabalho de atualização em pequenos pacotes, essa técnica é chamada de “time-slicing”:
A versão 16 do react foi lançada em 2017, então ja faz um tempinho que teve essa mudança para o novo algoritmo, portanto talvez você ja deve ter percebido ou ouvido falar que alguns dos updates de estados no react não ocorrem de forma sincrona, em resumo é justamente isso que o novo algoritmo permite.
Árvore de Componentes React
O ‘Fiber Reconciler’ deu nome de cada ‘trabalho’ a ser feito nos componentes como um nodo Fiber, ele é a representação de um trabalho pendente ou um trabalho ja feito.
Esse trabalho a ser feito podem set vários tipos de tarefas, como por exemplo: Executar um ‘Efeito’ como useEffect (link), ou atualizar o estado de um componente (link) e assim por diante.
Mas somente saber o que precisa ser feito não da para construir uma arvore de componentes, precisamos de relações entre cada nodo, portanto dentro de um Fiber temos informações extras que nos ajudam a montar uma arvore, como por exemplo o estado atual do componente, as props, e referencias para os filhos, irmãos e pai, e lista de atualizações pendentes.
Tendo uma estrutura de árvore fica bem mais fácil navegar entre os nodos, saber qual deles tem alguma pendência, as dependencias dos nodos entre si, etc. E para poder fazer tudo isso acontecer foi quebrado em fases.
Na primeira fase do algoritmo (normalmente chamada de “Render phase”), o reconciler precisa entender primeiro qual nó precisa ser adicionado, atualizado, deletado, e também se teve algum efeito colateral a ser computado.
Esses componentes “alterados” são justamente o que vão ser usados na segunda fase!
Para ficar tangível esse comportamento, sugiro ver esse vídeo.
Na segunda fase (normalmente chamada de “Commit phase”), o reconciler teve tempo de calcular todas as mudanças necessárias, portanto agora ele precisa fazer (nessa ordem):
Depois que todas as mudanças foram aplicadas, o reconciler limpa a fila de tarefas e espera o próximo update :) E assim finalizamos o ciclo de vida de um update no react.