Artur Bixiga
Voltar para o blog
Um Mergulho Profundo no OnPush do Angular, Futuro Zoneless e o Poder dos Signals

Desconstruindo o Change Detection do Angular: A História de Duas Estratégias

Em sua essência, a responsabilidade primária do framework Angular é manter uma sincronização perfeita entre o estado interno de uma aplicação—os dados mantidos dentro de seus componentes—e a interface do usuário apresentada no Document Object Model (DOM) do navegador. Esse processo, conhecido como change detection, é o motor que impulsiona a natureza dinâmica das aplicações web modernas. Quando um usuário clica em um botão, dados chegam de um servidor, ou um timer é concluído, o Angular detecta as mudanças de estado resultantes e atualiza a view de forma transparente. O framework oferece duas estratégias distintas para governar este processo crítico: Default e OnPush. Entender as diferenças profundas entre elas é o primeiro passo para dominar a performance da aplicação e construir para o futuro do framework.

O Mundo do "Verificar Tudo" do Default e a Mágica do zone.js

Para muitos desenvolvedores, o change detection do Angular parece mágica. Uma propriedade é atualizada em uma classe de componente, e momentos depois, o novo valor aparece na tela sem nenhum comando explícito para re-renderizar. Essa "mágica" é orquestrada por uma biblioteca poderosa de terceiros chamada zone.js.

Na inicialização da aplicação, o zone.js executa uma técnica conhecida como "monkey-patching". Ele intercepta e envolve quase todas as APIs assíncronas padrão do navegador. Isso inclui eventos DOM (click, mouseover, keyup), timers (setTimeout, setInterval), resoluções de Promise e até requisições XMLHttpRequest (Ajax). Quando qualquer uma dessas operações assíncronas é concluída, o wrapper do zone.js notifica o Angular que "algo pode ter mudado" dentro do estado da aplicação.

Essa notificação dispara um ciclo global de change detection governado pela estratégia Default, que é internamente referida como CheckAlways. Como o nome indica, essa estratégia é abrangente e indiscriminada. Ela inicia uma travessia top-down de toda a árvore de componentes, começando do componente raiz e visitando cada filho em um padrão de busca em profundidade. Durante essa travessia, o Angular avalia os bindings de template de cada componente, comparando valores de dados atuais com seus valores anteriores. Se uma discrepância é encontrada, a parte correspondente do DOM é atualizada. Essa verificação exaustiva garante que a UI seja sempre um reflexo fiel do estado da aplicação, mas vem com um custo significativo de performance. Cada componente é verificado durante cada ciclo, independentemente de seus próprios dados terem sido alterados, levando a um vasto número de computações potencialmente redundantes em aplicações grandes e complexas.

Apresentando o OnPush: A Abordagem Cirúrgica para Performance

A estratégia OnPush oferece uma alternativa drasticamente diferente e orientada a performance. Ao definir o change detection de um componente como OnPush, um desenvolvedor estabelece um contrato explícito com o framework Angular. O componente está essencialmente dizendo ao Angular: "Não me verifique nem aos meus descendentes durante um ciclo global de change detection, a menos que você tenha uma razão específica para acreditar que meu estado mudou".

Esse contrato permite que o Angular execute uma otimização crítica: podar a árvore de componentes. Durante um ciclo de change detection, quando a travessia alcança um componente marcado como OnPush que não foi explicitamente marcado como "dirty", o Angular pulará a verificação desse componente e de toda a sua subárvore. Essa capacidade de "cortar" branches inteiros da verificação pode reduzir drasticamente a carga de trabalho do motor de change detection.

As implicações de performance disso são melhor entendidas através de uma fórmula simples: P = N x C, onde performance (P) é uma função do número de componentes sendo verificados (N) e o custo de cada verificação (C). Enquanto o framework Angular é altamente otimizado para minimizar o custo de cada verificação individual (C), a alavanca mais poderosa do desenvolvedor para melhorar a performance é reduzir o número de verificações (N). A estratégia OnPush é a ferramenta primária para alcançar essa redução. Ela transforma o change detection de uma operação global de força bruta em um processo mais cirúrgico e eficiente.

Essa distinção destaca uma escolha arquitetural fundamental. A estratégia Default prioriza a conveniência do desenvolvedor, fornecendo um modelo implícito de "simplesmente funciona" à custa potencial de performance. Em contraste, OnPush prioriza performance e previsibilidade, mas requer que o desenvolvedor adote padrões mais disciplinados e explícitos para gerenciar estado. Essa mudança de um modelo implícito para um explícito não é meramente um ajuste de configuração; é um compromisso com uma arquitetura de aplicação mais deliberada e consciente de performance.

O Manual do OnPush: Dominando os Triggers e Abraçando a Imutabilidade

Implementar a estratégia OnPush com sucesso requer uma compreensão clara das condições específicas que marcam um componente como "dirty" e sinalizam ao Angular que ele precisa ser verificado. Esses triggers são a fundação do contrato OnPush, e dominá-los envolve um compromisso crucial com padrões de dados imutáveis.

Trigger 1: O @Input Imutável - A Regra de Ouro

A maneira mais comum para um componente OnPush receber novos dados é através de um binding @Input() de um componente pai. Quando isso acontece, o Angular executa uma comparação shallow na propriedade de input, usando uma verificação de identidade estrita equivalente a Object.is(). Isso significa que o Angular não está olhando para os valores dentro de um objeto ou array; ele está apenas verificando se a referência de memória do objeto ou array em si mudou. O componente só é marcado como dirty se uma nova referência é fornecida.

Isso leva a uma armadilha comum para desenvolvedores novos no OnPush: a armadilha da mutation. Considere um componente pai passando um objeto user para um componente filho OnPush.

A Armadilha da "Mutation" (Abordagem Incorreta):

// parent.component.ts
@Component({
  selector: 'app-parent',
  template: `
    <button (click)="updateUser()">Atualizar Nome do Usuário</button>
    <app-child [user]="user"></app-child>
  `,
})
export class ParentComponent {
  user = { name: 'Alice', age: 30 };

  updateUser() {
    // MUTATION: Isso muda uma propriedade no objeto existente.
    // A referência do objeto permanece a mesma.
    this.user.name = 'Bob';
  }
}

// child.component.ts
@Component({
  selector: 'app-child',
  template: `<p>Nome do Usuário: {{ user.name }}</p>`,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ChildComponent {
  @Input() user: { name: string; age: number };
}

Neste exemplo, clicar no botão faz mutation da propriedade name do objeto user. No entanto, como a referência ao objeto user em si não mudou, a verificação de identidade do Angular falha em detectar uma diferença. Como resultado, o componente filho não é marcado como dirty, e sua view não é atualizada. A UI continuará exibindo "Alice".

A Solução Imutável (Abordagem Correta):

Para disparar corretamente o change detection, o componente pai deve fornecer uma nova referência de objeto. Isso é tipicamente alcançado usando padrões de atualização imutável, como a sintaxe object spread.

// parent.component.ts (Corrigido)
export class ParentComponent {
  user = { name: 'Alice', age: 30 };

  updateUser() {
    // ATUALIZAÇÃO IMUTÁVEL: Isso cria um novo objeto com uma nova referência.
    this.user = { ...this.user, name: 'Bob' };
  }
}

Com essa mudança, o método updateUser agora cria um objeto completamente novo. Quando esse novo objeto é passado para o componente filho, a verificação de identidade do Angular detecta a mudança na referência, marca o componente filho como dirty e corretamente atualiza a view para exibir "Bob". Essa imposição de imutabilidade é um dos benefícios mais significativos do OnPush. Ela ativamente previne uma classe de bugs relacionados a estado mutável compartilhado. Quando um componente falha em atualizar, isso frequentemente serve como um sinal direto de que uma mutation imprópria ocorreu em algum lugar no fluxo de dados, efetivamente transformando a estratégia de change detection em um poderoso detector de bugs para anti-patterns de gerenciamento de estado.

Trigger 2: Eventos e o async Pipe - Os Triggers Automáticos

Enquanto mudanças de input requerem tratamento cuidadoso de imutabilidade, outros triggers funcionam mais automaticamente.

Eventos DOM: Quando um handler de evento é vinculado diretamente dentro do template de um componente OnPush (ex: (click)="doSomething()"), o Angular inerentemente sabe que o estado interno do componente pode ter mudado como resultado desse evento. Ao disparar do evento, o Angular executa um processo crucial de dois passos: ele marca o componente onde o evento originou como dirty, e também marca todos os ancestrais desse componente como dirty, até a raiz da aplicação. Esse "bubbling up" do estado dirty é uma salvaguarda que garante que o componente será alcançado e verificado durante o próximo ciclo de change detection, mesmo se estiver aninhado profundamente dentro de uma árvore de outros componentes OnPush que de outra forma seriam pulados.

O async Pipe: Para lidar com streams de dados assíncronos de Observables, o async pipe é uma ferramenta indispensável em uma arquitetura OnPush. Quando um novo valor é emitido de um observable que está sendo subscrito por um async pipe em um template, o pipe internamente chama markForCheck() no componente. Isso inicia o mesmo processo de "marcar e subir" como um evento DOM, agendando o componente de forma confiável para uma atualização. Isso faz do async pipe o método padrão e preferido para integrar streams de dados reativos em componentes OnPush, pois ele lida tanto com gerenciamento de subscription quanto com disparo de change detection automaticamente.

Trigger 3: Assumindo Controle Manual com ChangeDetectorRef

Em alguns cenários avançados, o estado de um componente pode mudar devido a lógica que não está ligada a uma mudança de input ou um evento vinculado ao template. Isso pode incluir callbacks de uma biblioteca de terceiros que o zone.js não faz patch, ou operações internas complexas que completam dentro de um bloco setTimeout ou Promise.then(). Nesses casos, o desenvolvedor deve sinalizar manualmente ao Angular que uma verificação é necessária. Isso é feito injetando o serviço ChangeDetectorRef e usando um de seus métodos.

É crítico entender a distinção entre os dois métodos primários disponíveis: markForCheck() e detectChanges().

  • markForCheck(): Este é o método mais seguro e mais comumente usado. Ele não dispara change detection imediatamente. Em vez disso, ele marca a view como dirty e, como um evento DOM, marca todos os ancestrais para verificação. Isso garante que o componente será incluído no próximo ciclo de change detection agendado, seja o atual ou um futuro. Ele funciona harmoniosamente com o scheduler existente do Angular sem forçar uma re-renderização imediata e potencialmente disruptiva.
  • detectChanges(): Este método é mais forçado e deve ser usado com cautela. Ele executa change detection imediatamente no componente atual e seus descendentes, bypassing o mecanismo normal de agendamento. Embora possa ser útil para situações específicas onde uma atualização imediata é necessária, também pode levar ao infame ExpressionChangedAfterItHasBeenCheckedError se usado impropriamente, pois pode causar mudança de estado após o Angular já ter completado sua verificação para o ciclo atual.

Em quase todos os casos onde intervenção manual é necessária, markForCheck() é a escolha correta e preferida, garantindo performance e estabilidade.

O Caminho para um Futuro Zoneless: Performance Liberada

Adotar a estratégia OnPush é mais do que uma otimização em nível de componente; é um passo fundacional em direção a uma arquitetura de aplicação mais moderna, performática e previsível. É o habilitador primário para uma das evoluções mais significativas na história do Angular: a capacidade de executar aplicações inteiramente sem zone.js.

Escapando da Zone: Por Que Importa

Enquanto o zone.js fornece a "mágica" conveniente da estratégia Default, ele vem com desvantagens notáveis. Primeiro, ele adiciona ao tamanho final do bundle da aplicação, aumentando a quantidade de JavaScript que os usuários devem baixar e parsear, o que pode impactar negativamente os tempos de carregamento inicial e métricas de Core Web Vitals. Segundo, ele introduz uma sobrecarga constante de runtime fazendo patch e rastreando cada operação assíncrona, muitas das quais podem não ter relação com o estado da UI da aplicação. Essa imprecisão frequentemente leva a mais ciclos de change detection do que estritamente necessário, consumindo recursos de CPU e bateria, particularmente em dispositivos móveis. Finalmente, os wrappers que o zone.js adiciona às APIs nativas podem complicar o debugging adicionando chamadas extras e específicas do framework aos stack traces, obscurecendo a verdadeira origem de um problema.

Como o OnPush Pavimenta o Caminho para um Mundo Zoneless

A transição para uma aplicação zoneless envolve transferir a responsabilidade de disparar change detection do mecanismo implícito e global do zone.js para um conjunto de sinais explícitos e bem definidos. Esta é precisamente a mentalidade que um desenvolvedor adota ao construir com OnPush.

Uma codebase construída inteiramente com componentes OnPush já está operando sob uma "filosofia zoneless". O desenvolvedor já parou de confiar na mágica ambiente do zone.js e em vez disso assumiu controle explícito, sinalizando mudanças de estado através dos triggers conhecidos do OnPush: novas referências @Input, eventos vinculados ao template, o async pipe e chamadas manuais a markForCheck().

Os triggers que o Angular usa para executar change detection em uma aplicação zoneless são uma correspondência quase perfeita com o manual do OnPush:

  • Um Signal atualizado é lido em um template.
  • O async pipe recebe um novo valor.
  • Um evento DOM vinculado em um template é disparado.
  • ChangeDetectorRef.markForCheck() é chamado manualmente.

Por causa desse alinhamento, converter uma aplicação inteira para usar OnPush é o passo preparatório mais importante para remover o zone.js. Uma aplicação que funciona corretamente e previsivelmente com OnPush em todos os lugares está, por sua natureza, pronta para ter o zone.js removido com mudanças adicionais mínimas.

Essa evolução representa um alinhamento estratégico do Angular com o ecossistema JavaScript mais amplo. Ao tornar o zone.js opcional, o Angular se torna menos um "jardim murado" com seus próprios mecanismos únicos e não-padrão. Essa mudança melhora a interoperabilidade com outras bibliotecas, simplifica o debugging com ferramentas padrão do navegador e torna o framework mais acessível para desenvolvedores vindos de outros backgrounds que estão acostumados a paradigmas de gerenciamento de estado mais explícitos. É um movimento em direção a um framework web mais padrão, moderno e transparente.

Os Benefícios Tangíveis de Ir Zoneless

Remover o zone.js e abraçar um modelo de change detection explícito e dirigido por OnPush produz vários benefícios concretos:

  • Performance: O benefício mais imediato é um tamanho de bundle de aplicação menor e um tempo de inicialização mais rápido. A eliminação de ciclos de renderização desnecessários reduz a carga de CPU, levando a uma experiência de usuário mais suave e melhor eficiência de bateria.
  • Previsibilidade: O change detection não é mais um evento misterioso e global. Ele se torna um processo determinístico que ocorre apenas em resposta a um conjunto conhecido de triggers explícitos. Isso torna o comportamento de renderização da aplicação mais fácil de raciocinar, debugar e testar.
  • Modernização: Aplicações zoneless são totalmente compatíveis com APIs modernas do navegador como async/await sem requerer os polyfills e processamento interno que o zone.js necessita, levando a código mais limpo e padrão.

A Parceria Perfeita: OnPush e Angular Signals

A introdução dos Angular Signals marca uma mudança de paradigma em como a reatividade é tratada dentro do framework. Enquanto o OnPush forneceu a fundação arquitetural para performance, os Signals fornecem o mecanismo refinado e eficiente para realizar plenamente seu potencial. Eles não são meramente compatíveis com OnPush; eles são sua contraparte perfeita, habilitando um nível de performance e ergonomia de desenvolvedor anteriormente inatingível.

Signals: Reatividade Granular Redefinida

Em sua forma mais simples, Signals são wrappers em torno de um valor que podem notificar consumidores interessados sempre que esse valor muda. Diferente do change detection global e de granularidade grossa disparado pelo zone.js, que verifica a aplicação inteira, os Signals criam um grafo reativo preciso e de granularidade fina. Quando um pedaço de código lê o valor de um signal chamando-o como uma função (ex: mySignal()), ele implicitamente se inscreve para mudanças desse valor específico. Isso permite que o Angular saiba exatamente quais partes da aplicação se importam com quais pedaços de estado.

Atualizações Automatizadas e Otimizadas em Componentes OnPush

A sinergia entre Signals e OnPush está em sua integração automática e transparente. Quando um signal é lido dentro do template de um componente OnPush, o motor de renderização do Angular automaticamente rastreia esse signal como uma dependência dessa view específica do componente.

Quando o valor do signal é posteriormente atualizado em outro lugar na aplicação (via os métodos .set(), .update() ou .mutate()), o signal notifica todos os seus dependentes. Para o componente que leu o signal em seu template, o Angular automaticamente o marca para verificação. Essa notificação direta e precisa elimina completamente a necessidade de padrões comuns do OnPush como usar o async pipe para estado simples ou injetar manualmente ChangeDetectorRef para chamar markForCheck().

Considere um componente contador simples refatorado para usar Signals:

@Component({
  selector: 'app-signal-counter',
  template: `
    <p>Contagem: {{ count() }}</p>
    <button (click)="increment()">Incrementar</button>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SignalCounterComponent {
  count = signal(0);

  increment() {
    this.count.update((value) => value + 1);
  }
}

Neste exemplo, quando o método increment atualiza o signal count, o Angular sabe que o template deste componente depende de count(). Ele automaticamente agenda o componente para uma atualização. Nenhum async pipe ou markForCheck é necessário. O código é mais limpo, mais declarativo e a reatividade é tratada com máxima eficiência.

O Alvorecer do Change Detection Local

O impacto mais profundo de combinar Signals com OnPush é a evolução em direção a uma nova forma hiper-otimizada de change detection, frequentemente referida como change detection "local" ou "glocal" (global-local). Isso representa uma melhoria fundamental sobre os mecanismos usados por triggers mais antigos como o async pipe.

Quando o async pipe recebe um novo valor, ele usa uma função chamada markViewDirty. Essa função marca o componente atual como dirty e então sobe, marcando todos os componentes ancestrais como dirty também. Durante a próxima execução de change detection, o Angular ainda deve atravessar todo esse caminho da raiz, verificando cada um dos ancestrais marcados até alcançar o componente alvo.

Signals, a partir do Angular v17, usam um mecanismo mais sofisticado. Quando uma atualização de signal dispara uma mudança, ela marca o "reactive consumer" interno do componente como dirty mas então usa uma função diferente chamada markAncestorsForTraversal. Essa função viaja pela árvore de componentes e marca os ancestrais apenas para travessia, não para verificação. Eles recebem uma flag HasChildViewsToRefresh mas não são eles mesmos considerados dirty.

Essa distinção é crucial. Quando o próximo ciclo de change detection executa, o Angular agora pode passar rapidamente pelos componentes ancestrais, pulando todas as verificações porque eles não estão marcados como dirty. Ele simplesmente segue as flags de travessia até chegar ao único componente cujo reactive consumer foi diretamente afetado pela mudança do signal. Nesse ponto, ele executa change detection apenas naquele componente específico.

Esta é a realização final da promessa do OnPush. Uma mudança de estado em um componente profundamente aninhado não incorre mais no custo de performance de reavaliar seus pais. Essa precisão cirúrgica é o futuro do motor de renderização do Angular, e é desbloqueada pela poderosa combinação de OnPush e Signals. Essa sinergia é tão completa que eleva o OnPush de uma mera estratégia de otimização para o estado natural e pretendido para qualquer componente Angular moderno dirigido por signals.

Conclusão: Um Novo Padrão para o Angular Moderno

A jornada através do cenário de change detection do Angular revela um caminho evolutivo claro e convincente. Ela começa com a estratégia Default conveniente mas ineficiente, alimentada pela rede global do zone.js. Ela progride para a estratégia OnPush disciplinada e performática, que exige intencionalidade do desenvolvedor e a recompensa com melhorias significativas de velocidade. Finalmente, ela culmina na poderosa parceria de OnPush e Signals, que entrega um nível de reatividade granular, localizada e automática que redefine o que é possível para performance no framework.

A evidência e direção arquitetural são inequívocas: ChangeDetectionStrategy.OnPush não deve mais ser visto como uma otimização opcional para gargalos de performance. Deve ser considerado a escolha padrão para todo novo desenvolvimento de componentes em aplicações Angular modernas. Seus princípios de mudanças de estado explícitas e imutabilidade levam não apenas a aplicações mais rápidas, mas também a codebases que são mais robustas, previsíveis e fáceis de manter.

O próprio time do Angular está considerando tornar OnPush a estratégia padrão do framework em uma versão futura, um testemunho de sua importância no ecossistema. Ao adotar OnPush e abraçar Signals hoje, desenvolvedores não estão apenas otimizando suas aplicações atuais; eles estão se alinhando com a direção futura do framework. Eles estão construindo aplicações que estão inerentemente preparadas para a era zoneless, aproveitando um modelo de renderização que é mais eficiente, mais transparente e mais poderoso do que nunca. O chamado à ação é claro: vá além do default e construa com o futuro em mente.