NG0100 ExpressionChangedAfterItHasBeenCheckedError: por que ele aparece no Angular zoneless

NG0100: ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked é o erro de dev-mode que todo dev Angular já viu. Depois de ir pra zoneless ou pro OnPush default do Angular 22, ele aparece em código e em teste que nunca tinham estourado. A causa mudou: em zoneless ele costuma dizer que um binding mudou sem notificar o Angular, que é o contrato do zoneless, e não um problema de timing de lifecycle que você empurra pro ngOnInit.

O erro, na íntegra

A mensagem é NG0100: ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked. Previous value: 'X'. Current value: 'Y'. Ele só estoura em desenvolvimento. Depois de cada rodada de change detection, o Angular faz uma segunda passada pra conferir que nada mudou, e se um binding agora lê diferente do que ele acabou de setar, ele lança. A checagem existe pra pegar uma view num estado inconsistente.

As causas clássicas são conversa velha: um getter ou método que devolve um valor novo a cada chamada, um componente filho escrevendo num binding do pai, um valor setado no ngAfterViewInit depois que o pai já tinha sido checado. Se fosse esse o seu caso, o fix seria o de manual, mover a escrita pro ngOnInit ou pro ngAfterContentInit, e você não estaria lendo isto. O que mudou foi onde o erro aparece agora.

Por que ele está aparecendo agora

Os times relatam uma onda de NG0100 logo depois de subir pro Angular 21, inclusive em teste unitário que estava verde no dia anterior, e o Angular 22 aumenta o raio ao tornar o OnPush o default de change detection. Se você não mexeu no componente mas o erro surgiu, o regime de change detection embaixo dele mudou.

Zoneless e OnPush são mais rígidos que o default com Zone.js. Com o Zone, uma passada ampla de change detection atualizava os bindings mesmo sem ninguém pedir explicitamente. Sem o Zone, um binding só atualiza quando algo notifica o Angular: uma escrita de signal, uma mudança de input, o async pipe ou o markForCheck. A passada de verificação do dev-mode agora também pega os bindings que o scheduler não teria atualizado, que é justamente o código que dependia do Zone sem ninguém perceber.

O que o NG0100 quer dizer em zoneless

Num app com Zone.js, o NG0100 costumava ser um problema de timing: duas passadas, valores diferentes, conserta o lifecycle hook. Em zoneless ele carrega mais informação. O Angular lança quando um binding foi atualizado sem notificação, sem escrita de signal, sem markForCheck, sem async pipe. É a mesma classe de bug que aparece quando um app vai pra zoneless: o valor mudou, mas nada avisou o Angular pra agendar a atualização.

Então leia o NG0100 em zoneless como um aviso adiantado, não como amolação. Em desenvolvimento ele estoura; em produção, sem a checagem de dev-mode, o mesmo binding simplesmente renderizaria desatualizado e você ia caçar fantasma. O erro está te fazendo um favor ao nomear o binding antes de ir pro ar.

De onde vem a mudançaPor que o zoneless lança NG0100Fix
Um timer ou callback muta um campoO campo mudou, nada agendou a atualizaçãoGuarde num signal, ou chame markForCheck
Um getter devolve valor novo a cada chamadaAs duas passadas leem valores diferentesMemoize, ou mova pra um computed signal
Um filho escreve num input do pai num hookO pai já tinha sido checado nesta passadaEscreva antes, ou modele como signal que o pai lê
O status de um reactive form controla a viewstatusChanges emitiu, a leitura no template não foi agendadaFaça a ponte do statusChanges pra um signal

A armadilha do teste: fixture.detectChanges()

O lugar novo mais comum de bater no NG0100 é um teste. Em zoneless, chamar fixture.detectChanges() depois de uma mutação estoura ele, porque o TestBed exige que o componente seja compatível com OnPush e acusa um binding atualizado sem notificação. O hábito de chamar detectChanges() depois de toda mudança virou o gatilho, não o conserto, e ele vinha escondendo a notificação que faltava esse tempo todo.

Pare de forçar change detection. Conduza o componente como a produção conduz, escreva por um canal de verdade e espere a estabilidade, pra o teste exercitar o mesmo caminho de notificação que o app usa. Um teste que passa só porque você chamou detectChanges() é um teste que deixaria passar um bug de view desatualizada.

Teste zoneless: conduza pela notificação, não force a detecçãots
// Antes: força CD, estoura NG0100 em zoneless, esconde a notificação que falta
component.label = 'atualizado';
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('atualizado');

// Depois: escreva pelo canal de verdade (um signal), aí espere a estabilidade
component.label.set('atualizado');
await fixture.whenStable();
expect(fixture.nativeElement.textContent).toContain('atualizado');

O fix quase nunca é silenciar

O movimento errado é espalhar ChangeDetectorRef.detectChanges() ou embrulhar a atribuição em setTimeout até o erro calar. Isso esconde a inconsistência que a checagem achou, e em zoneless esconde uma view que vai renderizar desatualizada em produção. Silenciar o NG0100 troca um erro barulhento de dev-mode por um silencioso em produção.

O fix certo nomeia onde o valor mudou e roteia por algo que o Angular observa. Um campo que um timer muta vira um signal. Um getter que recalcula vira um computed. Um callback de terceiro que atualiza a UI ganha um markForCheck na borda, ou um adapter que escreve um signal. Os fixes clássicos de lifecycle hook ainda valem quando a causa é mesmo timing; os casos de zoneless precisam de uma notificação, não de um segundo detectChanges.

Fix de produção: uma notificação, não um erro silenciadots
// Estoura NG0100 em zoneless: o campo muda, nada notifica o Angular
ngOnInit() {
  setInterval(() => { this.elapsed = this.elapsed + 1; }, 1000);
}

// Resolvido: um signal é uma notificação; a atualização do template é agendada
readonly elapsed = signal(0);
ngOnInit() {
  setInterval(() => this.elapsed.update((n) => n + 1), 1000);
}

Artefato para reaproveitar

Depurando o NG0100 em zoneless

  • Leia os valores Previous e Current na mensagem; eles nomeiam o binding que mudou.
  • Pergunte o que atualizou esse binding, e se algo notificou o Angular (signal, markForCheck, async pipe).
  • Mutação de campo por timer, callback ou terceiro: mova pra um signal, ou markForCheck na borda.
  • Um getter que devolve valor novo a cada chamada: memoize ou use um computed signal.
  • No teste, pare de chamar fixture.detectChanges() depois de mutação; escreva por canais de verdade e espere a estabilidade.
  • Não silencie com detectChanges ou setTimeout; em zoneless isso esconde uma view que fica desatualizada em produção.

Perguntas frequentes

O que é o NG0100 ExpressionChangedAfterItHasBeenCheckedError?

Um erro só de desenvolvimento que o Angular lança quando um binding de template muda de valor depois que o change detection já rodou. Em dev mode o Angular faz uma segunda passada de verificação; se um binding agora lê diferente do que acabou de setar, ele lança pra acusar uma view inconsistente.

Por que o NG0100 começou depois que subi pro Angular 21 ou 22?

Zoneless (o default em projeto novo desde a v21) e o OnPush como default na v22 são mais rígidos que o default com Zone.js. Um binding que o Zone atualizava de qualquer jeito agora só atualiza quando algo notifica o Angular, e a checagem de dev-mode pega os que dependiam do Zone sem ninguém perceber.

Por que o fixture.detectChanges() estoura NG0100 em teste zoneless?

Em zoneless, o TestBed exige que o componente seja compatível com OnPush. Chamar fixture.detectChanges() depois de uma mutação acusa um binding atualizado sem notificação. Escreva por canais de verdade (um signal, um evento) e espere a estabilidade, em vez de forçar a detecção.

O NG0100 é um problema de produção?

O erro em si é só de desenvolvimento. Mas em zoneless ele costuma apontar um binding que mudou sem notificar o Angular, o que em produção renderizaria desatualizado sem erro nenhum. Consertar tira um bug de verdade, não só um aviso.

Fontes consultadas

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.

Abrir playbook →

André Ramosdisponível para vagas remotas, UTC−3Entre em contato →

Leia também

Guia · 10 min

O código que quebra quando Angular fica zoneless

Um guia técnico para revisar timers, subscriptions, forms, callbacks de terceiros e testes antes de um spike zoneless no Angular.

Ler artigo →

Guia · 8 min

OnPush é o default no Angular 22: o que quebra e o que fazer

Por que o Angular 22 faz do OnPush o default, o que de fato quebra, os dois jeitos de corrigir, e como os Eager que a migração adicionou servem de backlog de limpeza.

Ler artigo →

Nota · 5 min

Um checklist curto antes de testar zoneless

Um checklist de prontidão para testar zoneless em uma aplicação Angular existente sem confundir spike de compatibilidade com plano de rollout.

Ler artigo →