Modern Angular
O código que quebra quando Angular fica zoneless
Zoneless expõe caminhos de código que alteram estado lido no template sem passar por uma notificação do Angular. Stable desde o Angular v20.2 (agosto/2025) e default em projetos novos a partir da v21 (novembro/2025), zoneless transforma um bug silencioso num alvo visível de review.
Comece pela notificação, não pelo nome da API
Um review de zoneless começa com uma pergunta: quando esse código muda algo que o template lê, qual caminho de notificação agenda o Angular?
O guia oficial de zoneless lista os caminhos de notificação: markForCheck, AsyncPipe, ComponentRef.setInput, atualização de signal lido no template, listeners de host ou template e anexar uma view marcada como suja. Código que altera estado de template fora desses caminhos vira o primeiro alvo de review.
Não remova todo NgZone.run() ou runOutsideAngular() mecanicamente. O alvo mais preciso do review são APIs de estabilidade como NgZone onStable, onUnstable, onMicrotaskEmpty ou lógica que trata isStable como fonte da verdade.
Alex Rickabaugh (Angular team) colocou a implicação no JS Party episódio 310: 'To turn off Zone is an application-wide decision, because Zone either as a library kind of has to be wrapping your application or not.' Ou seja, zoneless não é flag por componente. É uma postura que o app inteiro assume, e é por isso que o review começa pelo caminho de notificação, não pela superfície da API.
| Padrão | Por que quebra | Primeira correção a revisar |
|---|---|---|
| Timer muda um campo simples | O campo muda, mas Angular pode não ser agendado | Usar um signal lido no template ou chamar markForCheck |
| Subscription manual muda um campo | A subscription pode estar correta no lifecycle e ainda invisível para change detection | Usar AsyncPipe, toSignal ou markForCheck explícito |
| Status de reactive form dirige UI | O modelo do form emite observables, mas nem toda leitura no template é reagendada automaticamente | Ligar statusChanges ou valueChanges a um signal, ou marcar a view |
| Callback de terceiro atualiza estado de UI | O callback pode rodar fora dos caminhos de notificação do Angular | Criar um adapter que atualiza um signal ou marca a view |
Teste chama detectChanges() depois de toda mutação | O teste pode esconder notificações que faltariam em produção | Usar TestBed zoneless e esperar o Angular estabilizar |
Timer pode estar correto no lifecycle e invisível na UI
Este timer faz cleanup, mas isso não torna o código compatível com zoneless. O problema não é o intervalo. O problema é que um campo simples muda e nada avisa o Angular que uma leitura no template ficou desatualizada.
Se o template lê a hora atual, transforme o valor em signal, a menos que exista um motivo forte para manter campo simples. A dependência fica explícita e a exigência de cleanup continua visível.
export class ClockComponent {
private readonly destroyRef = inject(DestroyRef);
now = new Date();
ngOnInit() {
const intervalId = window.setInterval(() => {
this.now = new Date();
}, 1000);
this.destroyRef.onDestroy(() => {
window.clearInterval(intervalId);
});
}
}export class ClockComponent {
private readonly destroyRef = inject(DestroyRef);
readonly now = signal(new Date());
ngOnInit() {
const intervalId = window.setInterval(() => {
this.now.set(new Date());
}, 1000);
this.destroyRef.onDestroy(() => {
window.clearInterval(intervalId);
});
}
}Subscription segura não significa atualização visível
takeUntilDestroyed resolve um problema de lifecycle. Sozinho, ele não notifica o Angular que um campo simples atribuído dentro da subscription deve atualizar o template.
Para valores lidos no template, o ponto de consumo mais limpo costuma ser AsyncPipe ou toSignal. Se um campo simples precisa ficar porque o componente adapta código legado, o markForCheck() deve aparecer perto da atribuição.
export class OrdersBadgeComponent {
private readonly orders = inject(OrdersService);
private readonly destroyRef = inject(DestroyRef);
count = 0;
ngOnInit() {
this.orders.count$
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((count) => {
this.count = count;
});
}
}export class OrdersBadgeComponent {
private readonly orders = inject(OrdersService);
readonly count = toSignal(this.orders.count$, {
initialValue: 0,
});
}export class OrdersBadgeComponent {
private readonly orders = inject(OrdersService);
private readonly cdr = inject(ChangeDetectorRef);
private readonly destroyRef = inject(DestroyRef);
count = 0;
ngOnInit() {
this.orders.count$
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((count) => {
this.count = count;
this.cdr.markForCheck();
});
}
}Reactive forms precisam de uma ponte explícita
Reactive forms continuam sendo uma escolha madura. A cautela com zoneless é mais específica: se o template deriva UI de status, valid, errors ou validação customizada, deixe o caminho de notificação visível.
Converta para signal apenas o observable do form que o template precisa. O modelo continua sendo form, e o template ganha um valor rastreado.
export class CheckoutFormComponent {
private readonly fb = inject(NonNullableFormBuilder);
readonly form = this.fb.group({
email: '',
});
readonly status = toSignal(
this.form.statusChanges.pipe(startWith(this.form.status)),
{ initialValue: this.form.status }
);
readonly canSubmit = computed(() => this.status() === 'VALID');
}Callbacks de terceiros precisam de adapter
Charts, editors, mapas, drag-and-drop e overlays customizados são bons alvos de spike porque costumam misturar DOM manual, timers, observers e callbacks que o componente Angular não desenhou.
Não espalhe markForCheck() em callbacks. Crie um adapter pequeno: o evento de terceiro entra em um lugar, atualiza um signal ou marca a view explicitamente, e registra teardown.
export class SalesChartHostComponent {
private readonly chart = inject(SalesChartAdapter);
private readonly destroyRef = inject(DestroyRef);
readonly selectedPoint = signal<ChartPoint | null>(null);
ngAfterViewInit() {
const unsubscribe = this.chart.onPointClick((point) => {
this.selectedPoint.set(point);
});
this.destroyRef.onDestroy(unsubscribe);
}
}Testes não devem esconder notificações ausentes
Uma suíte de testes pode fazer zoneless parecer mais seguro do que é se toda asserção força outro fixture.detectChanges(). Isso prova que o template renderiza depois de uma change detection manual, não que o código de produção notificou o Angular.
O spike precisa de testes focados que configuram comportamento zoneless e usam whenStable() depois da ação. Se isso falhar, o componente mudou um valor de template sem passar por um caminho de notificação.
O Angular team foi explícito sobre a direção na RFC #66779: zoneless 'has created a requirement that components explicitly use ChangeDetectionStrategy.OnPush or at least be OnPush compatible.' Testes que escondem esse requisito deixam o código de produção passar do spike sem ajuste.
@Component({
selector: 'app-counter-badge',
standalone: true,
template: `<span>{{ count() }}</span>`,
})
class CounterBadgeComponent {
readonly count = signal(0);
setCountForTest(count: number) {
this.count.set(count);
}
}
beforeEach(() => {
TestBed.configureTestingModule({
imports: [CounterBadgeComponent],
providers: [provideZonelessChangeDetection()],
});
});
it('waits for Angular instead of forcing a second detectChanges', async () => {
const fixture = TestBed.createComponent(CounterBadgeComponent);
fixture.detectChanges();
fixture.componentInstance.setCountForTest(3);
await fixture.whenStable();
expect(fixture.nativeElement.textContent).toContain('3');
});A saída deve ser uma lista de correções, não um veredito
Não encerre um spike zoneless com 'funciona' ou 'não funciona'. Termine com uma tabela curta que nomeie o padrão quebrado, o caminho de notificação necessário e se a correção mora no componente, em um adapter compartilhado ou em upgrade de dependência.
Essa é a diferença entre adotar um default moderno do Angular e começar uma campanha arriscada de migração.
| Achado | Evidência | Decisão |
|---|---|---|
| Timer muda campo simples no resumo de checkout | Template só atualiza depois de detectChanges() forçado no teste | Converter para signal porque o valor é estado de template |
| Callback de ponto no chart atualiza estado do componente | Click atualiza dados, mas o label selecionado fica desatualizado | Mover callback para adapter do chart e atualizar um signal |
Botão de submit depende de form.status | Status muda, disabled state não refresca no spike | Conectar statusChanges com toSignal |
Biblioteca usa NgZone.onStable | Callback nunca roda na branch zoneless | Trocar por render hook ou esperar atualização da biblioteca |
Artefato para reaproveitar
Checklist de review de código para zoneless
- Procure timers, subscriptions manuais, leituras de status de form, callbacks de terceiros e APIs de estabilidade de
NgZone. - Para todo valor lido no template, identifique o caminho de notificação: signal,
AsyncPipe, input, evento de template oumarkForCheck. - Não trate todo
NgZone.run()ourunOutsideAngular()como bug; foque APIs de estabilidade e mutações escondidas lidas no template. - Separe correções de lifecycle e correções de change detection no code review.
- Adicione pelo menos um smoke test com TestBed zoneless que não force
detectChanges()depois da ação testada. - Termine o spike com lista de correções e decisão de adoção, não só com build passando.
Fontes consultadas
- https://angular.dev/guide/zoneless
- https://angular.dev/api/core/provideZonelessChangeDetection
- https://angular.dev/api/core/ChangeDetectorRef
- https://angular.dev/api/core/ChangeDetectionStrategy
- https://angular.dev/api/core/NgZone
- https://angular.dev/api/core/DestroyRef
- https://angular.dev/api/core/rxjs-interop/takeUntilDestroyed
- https://angular.dev/api/core/rxjs-interop/toSignal
- https://angular.dev/best-practices/zone-pollution
- https://angular.dev/best-practices/skipping-subtrees
- https://blog.angular.dev/announcing-angular-v20-b5c9c06cf301
- https://blog.angular.dev/announcing-angular-v21-57946c34f14b
- https://github.com/angular/angular/discussions/66779
- https://github.com/angular/angular/issues/55295
- https://changelog.com/jsparty/310
Modern Angular Playbook
Este artigo é um play.
O Playbook do Angular Moderno reúne o diagnóstico, a matriz de adoção, onze jogadas e o plano de 30 dias. Grátis, em inglês e português.
Você recebe os dois PDFs por email, pela lista do Dojo IA.
André Ramosdisponível para vagas remotas, UTC−3Entre em contato →