Modern Angular
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 names the notification paths: markForCheck, 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 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: '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.
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);
});
}
}A safe subscription is not automatically a visible update
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. 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.
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 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.
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.
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: 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.
@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
NgZonestability APIs. - For every template-read value, identify the notification path: signal,
AsyncPipe, input, template event, ormarkForCheck. - Do not treat every
NgZone.run()orrunOutsideAngular()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
Modern Angular Playbook
This article is one play.
The Modern Angular Playbook collects the diagnostic, the adoption matrix, eleven plays, and the 30-day plan. Free, in English and Portuguese.
You get both PDFs by email, through the Dojo IA list.
André Ramosavailable for remote roles, UTC−3Get in touch →