---
title: "O código que quebra quando Angular fica zoneless"
description: "Um guia técnico para revisar timers, subscriptions, forms, callbacks de terceiros e testes antes de um spike zoneless no Angular."
deck: "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."
author: "André Ramos"
url: "https://andreramos.dev/pt/angular/code-that-breaks-when-angular-goes-zoneless/"
lang: "pt-BR"
type: "article"
---

# 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](https://angular.dev/guide/zoneless) lista os caminhos de notificação: [`markForCheck`](https://angular.dev/api/core/ChangeDetectorRef), `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`](https://angular.dev/api/core/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](https://changelog.com/jsparty/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.

*Timer com atualização implícita*

```ts
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);
    });
  }
}
```

*Timer com signal e cleanup*

```ts
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`](https://angular.dev/api/core/rxjs-interop/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`](https://angular.dev/api/core/rxjs-interop/toSignal). Se um campo simples precisa ficar porque o componente adapta código legado, o `markForCheck()` deve aparecer perto da atribuição.

*Subscription segura, update invisível*

```ts
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;
      });
  }
}
```

*Borda com signal para leitura no template*

```ts
export class OrdersBadgeComponent {
  private readonly orders = inject(OrdersService);

  readonly count = toSignal(this.orders.count$, {
    initialValue: 0,
  });
}
```

*Fallback quando o campo simples precisa ficar*

```ts
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.

*Status do form exposto como Signals para o template*

```ts
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.

*Callback de terceiro adaptado com signal*

```ts
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](https://github.com/angular/angular/discussions/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.

*Smoke test com TestBed zoneless*

```ts
@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 reaproveitável: 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 ou `markForCheck`.
- Não trate todo `NgZone.run()` ou `runOutsideAngular()` 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

## Leia também

- [Um checklist curto antes de testar zoneless](https://andreramos.dev/pt/angular/zoneless-checklist/)
- [NG0100 ExpressionChangedAfterItHasBeenCheckedError: por que ele aparece no Angular zoneless](https://andreramos.dev/pt/angular/expressionchanged-error-zoneless-angular/)
- [Quando migrar uma suíte Angular para Vitest](https://andreramos.dev/pt/angular/move-angular-test-suite-to-vitest/)
