Zone.js era um mordomo invisível — e o Angular acabou de mandar ele embora
Migrei um design system pra Angular 21 zoneless. O código quase não mudou. O que mudou foi o que eu precisei entender pra dormir tranquilo.
Tem uma coisa que eu fazia há anos sem nunca ter parado pra pensar: eu escrevia this.total = a + b num método, a tela atualizava, e eu seguia a vida. Nunca me perguntei quem mandou a tela atualizar. Simplesmente acontecia. Era mágica.
A mágica tinha nome: zone.js. E na v21 o Angular tirou ela do caminho — zoneless virou o padrão, você não precisa nem chamar provideZonelessChangeDetection() mais. Quando eu mexi no projeto real, um design system Material 3 com testes em Jest, o susto não foi o tanto de código que quebrou. Foi perceber quanta coisa eu não sabia sobre uma engrenagem que rodava debaixo do meu nariz o dia inteiro.
O que o mordomo escondia de você
Zone.js era um mordomo obsessivo. Quando o Angular subia, ele fazia monkey-patch em basicamente toda API assíncrona do browser: setTimeout, addEventListener, Promise, fetch, XHR. Ele embrulhava tudo isso.
O trato era o seguinte: toda vez que qualquer uma dessas coisas terminava — um clique, um timer, uma resposta de HTTP — o mordomo batia na porta do Angular e dizia "ó, algo aconteceu, é bom você conferir". E o Angular ia lá e reconferia a árvore de componentes inteira pra ver se algum valor tinha mudado.
Repara no detalhe que a gente ignora: ele não sabia o que mudou. Ele só sabia que algo assíncrono aconteceu. Um setInterval de analytics rodando no canto da tela disparava change detection na aplicação toda, mesmo que nenhum pixel precisasse mudar. Funcionava, e funcionava bem o suficiente pra ninguém reclamar por dez anos. Mas era um modelo baseado em suspeita, não em certeza: "aconteceu algo, melhor checar tudo".
O preço disso é duplo. Tem o custo de runtime — reconferir componente que não mudou é trabalho jogado fora. E tem um custo mais sorrateiro, que é o que me pegou: você nunca precisou aprender quando o Angular re-renderiza. O mordomo pensava por você.
Zoneless te obriga a saber a regra
Sem o zone.js, ninguém mais bate na porta a cada evento assíncrono. Então a pergunta que eu nunca tinha respondido virou obrigatória: quando exatamente o Angular decide re-renderizar?
A resposta, na v21, é uma lista curta e honesta. O Angular marca uma view pra ser checada quando:
- um signal que é lido no template tem o valor atualizado;
ChangeDetectorRef.markForCheck()é chamado (oAsyncPipefaz isso por baixo, é assim que| asynccontinua funcionando);ComponentRef.setInput()roda;- um listener de evento do host ou do template dispara (um
(click), por exemplo); - uma view marcada como suja por um desses é anexada.
E é só isso. Não tem mais "algo assíncrono aconteceu logo confere tudo". Tem gatilhos explícitos e nomeáveis. A primeira vez que eu li essa lista foi meio constrangedor — dez anos de Angular e eu estava aprendendo agora a regra mais básica do framework. Mas é exatamente esse o ganho escondido do zoneless: ele troca mágica por um contrato que cabe na cabeça.
Signal é a nova unidade de reatividade
O primeiro item daquela lista é o coração de tudo. signal, computed, effect — essa é a fundação nova.
Um signal é um valor que sabe quem o observa. Quando você lê um signal dentro de um template, o Angular registra ali uma dependência: "esta view depende deste valor". Quando você escreve nesse signal, ele marca pra checagem exatamente as views que o leem. Nem uma a mais. É reatividade cirúrgica: o dado avisa quem precisa saber, e ninguém mais é incomodado.
Tem uma frase dessa história que precisa entrar tatuada, porque é a origem de metade dos bugs de quem migra:
pra atualização do signal refletir na tela, ele tem que ser lido no template.
Se você atualiza um signal que nenhum template lê, nada re-renderiza. E está certíssimo que seja assim — não tem dependência, não tem motivo pra checar. Mas depois de uma década de "muda o campo e a tela se vira", isso dá um nó no cérebro no começo.
Na prática, mudou como eu estruturo estado. Antes eu tinha campos soltos na classe e confiava no mordomo pra sincronizar. Agora estado que a UI enxerga tende a virar signal, valor derivado vira computed (que recalcula sozinho e faz cache), e o que era ngOnChanges cheio de if pra reagir a input virou input signal com effect. O componente parou de ser um saco de propriedades que o framework vigia, e virou um grafo de dependências que eu declaro. Menos coisa acontecendo pelas minhas costas.
O que você ganha, e o que passa a ser sua conta
O ganho de performance é real — sem checar a árvore inteira a cada tick, você atualiza só o que mudou. Mas sinceramente, num app de tamanho médio, não é o ganho de FPS que me convenceu. Foi a previsibilidade. Quando algo não atualiza na tela, hoje eu tenho uma lista finita de suspeitos. Antes era "vai saber, o zone.js decide".
O custo é o outro lado exato dessa moeda: a sincronização virou sua responsabilidade consciente. Você passa a pensar "esse valor é lido no template? então precisa ser signal ou passar por um dos gatilhos". Código que vem de fora do mundo Angular — o callback de uma lib de terceiro, um WebSocket cru — não avisa mais ninguém sozinho; você tem que fechar esse fio na mão, jogando o resultado num signal. É mais uma coisa pra pensar. Só que, diferente do mordomo, é uma coisa que você consegue pensar, porque a regra é explícita.
As armadilhas que me pegaram (a maioria nos testes)
O código de produção migrou mais liso do que eu temia. O sangue foi na suíte de testes.
fakeAsync e tick() param de funcionar. Esses dois eram construídos em cima do zone.js — era o mordomo que dava pra eles o poder de fingir a passagem do tempo. Sem zona, morrem. No meu projeto com Jest, a saída foi trocar por fake timer do próprio Jest: jest.useFakeTimers() e jest.advanceTimersByTime(ms) pra empurrar setTimeout/setInterval, e await fixture.whenStable() pra esperar o Angular assentar, no lugar de espremer detectChanges() no meio do teste.
detectChanges() deixa de ser reflexo. O recomendado passou a ser await fixture.whenStable(). E dentro do TestBed, pra forçar um flush síncrono de effect, existe TestBed.tick() — que é diferente do velho tick() do fakeAsync, não confunda os dois.
OnPush deixa de ser otimização e vira o modelo mental. Não é obrigatório, mas zoneless é essencialmente a mentalidade OnPush aplicada à aplicação inteira: só checa quando algo declaradamente mudou. Vale alinhar a cabeça nisso.
O antídoto da mágica é entender
O que ficou pra mim não foi a lista de APIs — signal, computed, effect você aprende num fim de tarde. Foi outra coisa.
Por dez anos eu construí em cima de uma engrenagem que eu não sabia explicar. Funcionava, então eu não olhava. O zoneless não me deu um framework melhor de graça; ele me tirou o mordomo e me obrigou a aprender a regra que ele executava por mim. E aprender isso me deixou um dev melhor do que qualquer atalho deixaria.
É por isso que eu desconfio da mágica que atualiza a tela sozinha, do abstração que "só funciona", do tutorial que te faz sentir produtivo sem te fazer entender. Construir de verdade é justamente o oposto: é abrir a caixa, olhar a engrenagem, e conseguir explicar por que a tela mudou. Quando você sabe quem mandou re-renderizar, você parou de rezar pro framework — e começou a programar ele. 🌀