EthicsComputerCourses
Join training +91 90532 10052

Change Detection & Zoneless Apps

Angular · Lesson 31 of 41 Advanced 7 min read Updated

Change detection is the process Angular uses to keep the page in sync with your data. When something changes, Angular re-evaluates template bindings and updates only the DOM nodes whose values are different. Most of the time you never think about it, but knowing how it works explains why signals matter so much, and why some old code stops updating the screen in a modern app.

Angular 22 makes two big defaults official: new apps are zoneless, and components use the OnPush strategy unless you say otherwise.

The old way: zone.js checks everything

For most of Angular's history, every app loaded a library called zone.js. It patched browser APIs such as setTimeout, addEventListener and fetch, so Angular was told whenever any asynchronous task finished. Angular then ran change detection over the whole component tree, top to bottom, comparing every binding.

This "check everything after anything" approach was convenient: you could change a plain field anywhere and the screen updated. But it had costs: extra JavaScript to download, wasted work checking components that had not changed, confusing stack traces, and trouble with native async/await.

Zoneless: Angular is told exactly when to update

Since Angular 21, new apps are created without zone.js, and in Angular 22 that is the normal setup: no zone.js in package.json, no polyfill, no extra provider. Instead of guessing, Angular schedules change detection when it receives a notification. These are the notifications:

  • A signal read by a template changes (set(), update(), a computed or resource updating).
  • An event listener bound in a template or a host object runs, such as (click).
  • ChangeDetectorRef.markForCheck() is called (the async pipe calls it for you).
  • ComponentRef.setInput() sets an input on a dynamically created component.

Anything else, such as a plain field changed inside setTimeout, a promise callback or a third-party library's callback, does not tell Angular to update.

OnPush by default

A component's change detection strategy decides when Angular checks that component:

  • OnPush (default in Angular 22): the component is checked only when one of its inputs gets a new value, an event fires in its template, a signal it reads changes, or it is explicitly marked. Otherwise Angular skips it and its children, which saves a lot of work in large apps.
  • Eager (ChangeDetectionStrategy.Eager): the component is checked every time change detection runs. This was the default before Angular 22, under the name ChangeDetectionStrategy.Default, which is now deprecated.

With signals, OnPush needs no extra thought: reading a signal in a template automatically marks the component when the signal changes.

Seeing the difference

Here are two versions of an OTP countdown. The first stores the number in a plain field and changes it in a timer:

TypeScript
import { Component } from '@angular/core';

@Component({
  selector: 'app-otp-timer-broken',
  template: `<p>Resend OTP in {{ secondsLeft }} s</p>`,
})
export class OtpTimerBroken {
  protected secondsLeft = 30;

  constructor() {
    setTimeout(() => (this.secondsLeft = 29), 1000);
  }
}

The second uses a signal:

TypeScript
import { Component, signal } from '@angular/core';

@Component({
  selector: 'app-otp-timer',
  template: `<p>Resend OTP in {{ secondsLeft() }} s</p>`,
})
export class OtpTimer {
  protected secondsLeft = signal(30);

  constructor() {
    setTimeout(() => this.secondsLeft.set(29), 1000);
  }
}

After two seconds, the screens show:

Output
OtpTimerBroken:  Resend OTP in 30 s
OtpTimer:        Resend OTP in 29 s

The plain field really is 29, but nothing notified Angular, so the old text stays. In a zone.js app the first version would have worked, which is why such code breaks when old apps go zoneless. The fix is almost always the same: store state in signals.

Plain fields still work when they change inside a template event handler, because the event itself is a notification:

TypeScript
@Component({
  selector: 'app-click-counter',
  template: `<button (click)="count = count + 1">Clicked {{ count }} times</button>`,
})
export class ClickCounter {
  protected count = 0;
}

Two clicks show "Clicked 2 times". Still, signals are the safer habit, because the same code may later be called from a timer or a service.

Escape hatches for older code

If you cannot change a component to signals yet, inject ChangeDetectorRef and call markForCheck() after updating fields:

TypeScript
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, inject } from '@angular/core';

@Component({
  selector: 'app-otp-timer-old',
  changeDetection: ChangeDetectionStrategy.Eager,
  template: `<p>Resend OTP in {{ secondsLeft }} s</p>`,
})
export class OtpTimerOld {
  protected secondsLeft = 30;
  private cdr = inject(ChangeDetectorRef);

  constructor() {
    setTimeout(() => {
      this.secondsLeft = 29;
      this.cdr.markForCheck();
    }, 1000);
  }
}

When you run ng update from Angular 21 to 22, a migration adds changeDetection: ChangeDetectionStrategy.Eager to existing components that did not set a strategy, so their behaviour does not change. You can then move them to signals and remove Eager one by one.

Apps that still depend on zone.js can keep it: they list zone.js in the polyfills of angular.json and add provideZoneChangeDetection() to their providers. To move such an app to zoneless, remove both, run npm uninstall zone.js, and fix any component that changes plain fields outside event handlers.

The NG0100 error

In development mode, Angular checks a second time that bindings did not change during a check. If they did, you get:

Output
NG0100: ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked.

In zoneless apps and in tests you also see it when a value changed without a notification, which points straight at code that should use a signal. Do not hide it with setTimeout; fix the data flow.

Common mistakes

  • Updating plain fields in timers, promises or library callbacks. The screen does not update. Use signals, or markForCheck() in old code.
  • Mutating an input object in an OnPush child. this.student.name = 'x' keeps the same object, so the child is not checked. Pass a new object, or better, use signals.
  • Adding zone.js back to fix a bug. It hides the real problem and slows the app. Find the change that lacks a notification.
  • Calling detectChanges() everywhere. It forces a check of one component synchronously; occasional use is fine, but it should not replace signals.

Key points

  • Change detection updates the DOM when bound values change.
  • Angular 22 apps are zoneless by default: Angular updates only after notifications such as signal changes, template events and markForCheck().
  • Components use OnPush by default in Angular 22; ChangeDetectionStrategy.Eager restores the old check-every-time behaviour.
  • Plain fields changed in timers or promise callbacks do not update the screen; store state in signals.
  • Older apps may use zone.js with provideZoneChangeDetection(); migrating away means moving state to signals.

Exercise

A component shows the live price of a gold coin. A third-party price feed calls onPrice(callback) with a new number every few seconds. The current code stores the price in a plain field price = 0 and sets this.price = p in the callback, but the screen never changes. Rewrite the component so the price updates in a zoneless app, and add a computed that shows the price with 3% GST.

Show solution
TypeScript
import { Component, DestroyRef, computed, inject, signal } from '@angular/core';
import { PriceFeed } from './price-feed';

@Component({
  selector: 'app-gold-price',
  template: `
    <p>Gold coin: ₹{{ price() }}</p>
    <p>With GST: ₹{{ withGst() }}</p>
  `,
})
export class GoldPrice {
  protected price = signal(0);
  protected withGst = computed(() => Math.round(this.price() * 1.03));

  constructor() {
    const stop = inject(PriceFeed).onPrice((p) => this.price.set(p));
    inject(DestroyRef).onDestroy(stop);
  }
}

Setting a signal is a notification, so Angular re-renders the component even though the callback comes from outside Angular. The DestroyRef clean-up stops the feed (assuming onPrice returns an unsubscribe function) when the component is removed.

Quick Quiz

1. What is the default change detection strategy for components in Angular 22?

2. In a zoneless app, which change does NOT update the screen by itself?

3. What does the Angular 21 to 22 migration add to existing components without a strategy?

4. What did zone.js do in older Angular apps?

Learn Angular with a mentor

Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call