---
title: "The Code That Breaks When Angular Goes Zoneless"
description: "A code-heavy review guide for timers, subscriptions, forms, third-party callbacks, and tests before a zoneless Angular spike."
deck: "Zoneless exposes code paths that mutate template state without one of Angular's notification paths. Stable since Angular v20.2 (August 2025) and default in new projects from v21 (November 2025), zoneless turns a quiet bug into a visible review target."
author: "André Ramos"
url: "https://andreramos.dev/angular/code-that-breaks-when-angular-goes-zoneless/"
lang: "en"
type: "article"
---

# The Code That Breaks When Angular Goes Zoneless

> Zoneless exposes code paths that mutate template state without one of Angular's notification paths. Stable since Angular v20.2 (August 2025) and default in new projects from v21 (November 2025), zoneless turns a quiet bug into a visible review target.

## Start with the notification, not the API name

A zoneless review starts with one question: when this code changes something the template reads, which notification path schedules Angular?

The [official zoneless guide](https://angular.dev/guide/zoneless) names the notification paths: [`markForCheck`](https://angular.dev/api/core/ChangeDetectorRef), `AsyncPipe`, `ComponentRef.setInput`, updating a signal read in the template, bound host or template listeners, and attaching a dirty view. Code that changes template state outside those paths becomes the first review target.

Do not mechanically delete every `NgZone.run()` or `runOutsideAngular()` call. A better review target is stability code such as [`NgZone`](https://angular.dev/api/core/NgZone) APIs `onStable`, `onUnstable`, `onMicrotaskEmpty`, or logic that treats `isStable` as the source of truth.

Alex Rickabaugh (Angular team) framed the implication on [JS Party episode 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.' Zoneless is not a per-component flag. It is a posture the whole app commits to, which is why the review starts with the notification path, not the API surface.

| Pattern | Why it breaks | First fix to review |
|---|---|---|
| Timer mutates a plain field | The field changes, but Angular may not be scheduled | Use a template-read `signal` or call `markForCheck` |
| Manual subscription mutates a field | The subscription is lifecycle-safe but still invisible to change detection | Use `AsyncPipe`, `toSignal`, or explicit `markForCheck` |
| Reactive form status drives template state | Form model updates emit observables, but do not automatically schedule every template read | Bridge `statusChanges` or `valueChanges` into a signal or mark the view |
| Third-party callback updates UI state | The callback can run outside Angular's notification paths | Wrap it in an adapter that updates a signal or marks the view |
| Test calls `detectChanges()` after every mutation | The test can hide missing production notifications | Use zoneless TestBed and wait for Angular to stabilize |

## Timer code can be lifecycle-safe and still invisible

This timer cleans itself up, but that does not make it zoneless-compatible. The problem is not the interval. The problem is that a plain field changes and nothing tells Angular that a template read became stale.

If the template reads the current time, make the value a signal unless there is a stronger reason to keep a plain field. That makes the dependency explicit and keeps the cleanup requirement visible.

*Timer with implicit change detection*

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

*Signal-driven timer with 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);
    });
  }
}
```

## A safe subscription is not automatically a visible update

[`takeUntilDestroyed`](https://angular.dev/api/core/rxjs-interop/takeUntilDestroyed) fixes a lifecycle problem. It does not, by itself, notify Angular that a plain field assigned inside the subscription should refresh the template.

For template reads, the cleaner boundary is `AsyncPipe` or [`toSignal`](https://angular.dev/api/core/rxjs-interop/toSignal). If a plain field must remain because the component is adapting legacy code, put `markForCheck()` next to the assignment so the notification path is visible.

*Safe subscription, invisible update*

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

*Signal boundary for template reads*

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

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

*Fallback when a plain field must stay*

```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 need an explicit bridge

Reactive forms remain a mature default. The zoneless caveat is narrower: if the template derives UI state from `status`, `valid`, `errors`, or a custom validation signal, make the notification path visible.

Convert only the form observable the template needs into a signal. The form model stays a form model, and the template gets a tracked value.

*Form status exposed as template-readable Signals*

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

## Third-party callbacks need an adapter boundary

Charts, editors, maps, drag-and-drop packages, and custom overlays are good spike targets because they often mix DOM work, timers, observers, and callbacks that the Angular component did not design.

Do not scatter `markForCheck()` across callback bodies. Create a small adapter boundary: the third-party event enters one place, updates a signal or explicitly marks the view, and registers teardown.

*Third-party callback adapted into a signal boundary*

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

## Tests should stop hiding missed notifications

A test suite can make zoneless look safer than it is if every assertion forces another `fixture.detectChanges()`. That proves the template can render after manual change detection, not that production code notified Angular.

A spike needs focused tests that configure zoneless behavior and then use `whenStable()` after the action. If that fails, the component changed a template value without one of the notification paths.

The Angular team has been explicit about the direction in [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.' Tests that hide that requirement let production code drift past the spike.

*Zoneless TestBed smoke check*

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

## The output should be a fix list, not a claim

Do not end a zoneless spike with 'works' or 'does not work'. End with a small table that names the exact broken pattern, the notification path it should use, and whether the fix belongs in the component, a shared adapter, or a dependency upgrade.

That is the difference between adopting a modern Angular default and starting a risky migration campaign.

| Finding | Evidence | Decision |
|---|---|---|
| Timer mutates plain field in checkout summary | Template updates only after a forced `detectChanges()` in test | Convert to signal because the value is template state |
| Chart point callback updates component state | Click handler updates data, selected label stays stale | Move callback into chart adapter and update a signal |
| Form submit button depends on `form.status` | Status changes, disabled state does not refresh in spike | Bridge `statusChanges` with `toSignal` |
| Library uses `NgZone.onStable` | Callback never runs in zoneless branch | Replace with render hook or wait for library update |

## Reusable artifact: Zoneless code review checklist

- Search for timers, manual subscriptions, form status reads, third-party callbacks, and `NgZone` stability APIs.
- For every template-read value, identify the notification path: signal, `AsyncPipe`, input, template event, or `markForCheck`.
- Do not treat every `NgZone.run()` or `runOutsideAngular()` call as a bug; focus on stability APIs and hidden template mutations.
- Keep lifecycle fixes and change detection fixes separate in code review.
- Add at least one zoneless TestBed smoke test that does not force `detectChanges()` after the action under test.
- End the spike with a fix list and adoption decision, not just a passing build.

## Sources checked

- 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

## Related

- [A Small Checklist Before Trying Zoneless](https://andreramos.dev/angular/zoneless-checklist/)
- [NG0100 ExpressionChangedAfterItHasBeenCheckedError: Why It Surfaces in Zoneless Angular](https://andreramos.dev/angular/expressionchanged-error-zoneless-angular/)
- [When to Move an Angular Test Suite to Vitest](https://andreramos.dev/angular/move-angular-test-suite-to-vitest/)
