DEV Community

Ojas Deshpande
Ojas Deshpande

Posted on • Originally published at ojas-deshpande.com

Angular Change Detection: The Evolution from Zone.js to Signals

Introduction

As applications grow in complexity, understanding how Angular detects and propagates changes becomes critical for performance. For years, developers relied on the "magic" of Zone.js, but the modern Angular landscape has shifted toward more explicit, high-performance patterns — OnPush first, and now fine-grained reactivity with Signals.

As a Principal Architect, I've seen teams struggle with jank and unnecessary re-renders that stem from a shallow understanding of change detection mechanics. This is the architectural guide to mastering change detection in the modern Angular era: how Zone.js actually works, what OnPush buys you, and how Signals change the picture entirely.

A deep architectural guide to Angular change detection — how Zone.js actually works, what OnPush buys you, and how Signals change the picture entirely.


The Era of "Magic": Zone.js and the Default Strategy

By default, Angular uses Zone.js to monkey-patch every asynchronous API in the browser: setTimeout, setInterval, Promise.then, XMLHttpRequest, fetch, event listeners. When Angular boots, it runs your entire application inside a zone. When any patched async operation completes — a timer fires, a Promise resolves, a click handler runs, an HTTP response arrives — Zone.js notifies Angular: "Something happened; you should probably check."

Angular responds by walking the entire component tree from root down. For each component, it compares the current value of every template expression against its previous value. If anything changed, it updates the corresponding DOM nodes.

The key insight: Angular doesn't watch your data. It watches for async operations, because async operations are what change data. When something async happens, Angular assumes something might have changed and checks everything.

The good: It works out of the box. You update a variable, and the UI updates. The mental model is simple.

The bad: It's dirty-checking at its most expensive. In a large app, clicking a single button can trigger change detection across hundreds of components that haven't changed at all. As the component tree grows, this top-down check becomes a major performance bottleneck.


The Performance Bridge: ChangeDetectionStrategy.OnPush

OnPush changes the contract for a component. Instead of checking it on every async event, Angular only checks it when one of four conditions is met:

  1. An @Input() reference changes. Not a mutation — a new reference. If the parent mutates an existing object, OnPush children are not triggered.
  2. An async pipe emits a new value. The async pipe internally calls markForCheck() when its Observable or Promise emits. This is why the async pipe is the best friend of OnPush.
  3. markForCheck() is called explicitly via ChangeDetectorRef. This is the correct escape hatch when you need to trigger detection outside the standard mechanisms.
  4. A DOM event originates from the component or its children. Click handlers and input events within an OnPush component still trigger detection for that component.
@Component({
  selector: 'app-user-card',
  template: `<div>{{ user.name }}</div>`,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class UserCardComponent {
  @Input() user!: User;
}
Enter fullscreen mode Exit fullscreen mode

With this configuration, UserCardComponent is skipped entirely if the parent triggers change detection 100 times but hasn't passed a new user reference.

The async pipe handles the second trigger condition for you automatically:

@Component({
  selector: 'app-user-list',
  template: `
    @if (users$ | async; as users) {
      @for (user of users; track user.id) {
        <app-user-card [user]="user" />
      }
    }
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class UserListComponent {
  users$ = this.userService.getUsers(); // Observable<User[]>

  constructor(private readonly userService: UserService) {}
}
Enter fullscreen mode Exit fullscreen mode

The async pipe subscribes to users$, and when the Observable emits, it calls markForCheck() internally — the component re-renders with the new data without you touching ChangeDetectorRef at all.

When you do need to call markForCheck() explicitly — for example when updating state from a callback that runs outside Angular's normal data flow:

@Component({
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class NotificationComponent implements OnInit {
  message = '';

  constructor(
    private readonly cdr: ChangeDetectorRef,
    private readonly notificationService: NotificationService
  ) {}

  ngOnInit(): void {
    // Third-party library callback — runs outside Angular's zone
    this.notificationService.onMessage((msg: string) => {
      this.message = msg;
      this.cdr.markForCheck(); // Tell Angular to check this component on next cycle
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Immutability is mandatory. Since OnPush checks reference equality, you cannot mutate object properties:

// Will NOT trigger OnPush child to re-render
this.user.name = 'New Name'; // Same reference

// Will trigger re-render
this.user = { ...this.user, name: 'New Name' }; // New reference
Enter fullscreen mode Exit fullscreen mode

This is a feature, not a limitation. Immutable state updates are predictable and traceable. OnPush makes mutability visible by breaking your UI when you do it — which is exactly what you want in a large codebase.


Running Outside Angular's Zone

Some async operations don't affect the UI: logging, analytics, background processing. Running these inside Angular's zone triggers unnecessary change detection cycles. NgZone.runOutsideAngular() lets you escape:

constructor(private readonly ngZone: NgZone) {
  this.ngZone.runOutsideAngular(() => {
    // High-frequency WebSocket messages processed here
    // No change detection triggered per message
    this.websocket.messages$.subscribe(msg => {
      this.latestState = processMessage(msg);
      // Only re-enter the zone when the view actually needs to update
      if (this.stateChanged) {
        this.ngZone.run(() => this.cdr.markForCheck());
      }
    });
  });
}
Enter fullscreen mode Exit fullscreen mode

This pattern is particularly important for high-frequency data streams. Processing WebSocket messages outside the zone and only re-entering when something the view renders has changed is the correct architecture for reactive, data-intensive Angular applications.

What to avoid: Injecting ChangeDetectorRef to call detectChanges() repeatedly throughout your components. If you find yourself doing this often, it's a signal that your data flow is too complex or your state management isn't reactive enough. The correct tools are the async pipe and signals. detectChanges() is appropriate for Canvas or WebGL components that manage their own render loop and need a single explicit trigger — not as a general-purpose workaround.


The Modern Frontier: Signals and Fine-Grained Reactivity

Angular Signals, stable in Angular 17+, represent the most significant shift in change detection since the framework's inception. They move Angular from "checking the tree" to "notifying the specific node."

A signal is a value wrapped in a reactive container. When you read a signal in a template, Angular tracks the dependency. When the signal changes, Angular knows precisely which components depend on it and marks only those for re-rendering — no tree walk, no zone notification, no checking of unrelated components.

@Component({
  selector: 'app-counter',
  template: `
    <p>Count: {{ count() }}</p>
    <p>Double: {{ double() }}</p>
    <button (click)="increment()">Increment</button>
  `
})
export class CounterComponent {
  count = signal(0);
  double = computed(() => this.count() * 2);

  increment(): void {
    this.count.update(c => c + 1);
  }
}
Enter fullscreen mode Exit fullscreen mode

Why signals are a game changer:

Fine-grained updates. Unlike Zone.js which triggers a global check, a signal knows exactly where it is used. When its value changes, Angular updates only that specific part of the DOM.

Computed signals are lazy. computed() only recomputes when read after a dependency has changed. If double is not read after count changes, no computation occurs. This laziness is the performance model.

Zoneless potential. Signals are the prerequisite for a fully zoneless Angular. Removing Zone.js reduces initial bundle size and eliminates the overhead of monkey-patching browser APIs.

No more ExpressionChangedAfterItHasBeenCheckedError. Because signal updates are synchronous and predictable, this notorious error — which has haunted Angular developers for years — becomes a thing of the past.


Signals vs RxJS: Different Tools

The first question developers ask about signals is whether they replace RxJS. They don't — they solve different problems.

Signals are for synchronous reactive state: values that change over time and drive the UI. Simple to create, simple to read, naturally integrated with Angular's template system.

RxJS is for asynchronous event streams: HTTP responses, WebSocket messages, complex event sequences, time-based operations. The operator ecosystem — switchMap, debounceTime, combineLatest, catchError — has no equivalent in signals.

The practical pattern is to use both. toSignal() and toObservable() from @angular/core/rxjs-interop bridge between them:

import { toSignal, toObservable } from '@angular/core/rxjs-interop';

@Component({ ... })
export class ProductsComponent {
  private readonly products$ = this.productService.getProducts();

  // Observable → Signal for use in template
  products = toSignal(this.products$, { initialValue: [] });

  // Signal → Observable for RxJS pipeline
  private readonly searchTerm = signal('');
  private readonly results$ = toObservable(this.searchTerm).pipe(
    debounceTime(300),
    switchMap(term => this.productService.search(term))
  );
  results = toSignal(this.results$, { initialValue: [] });
}
Enter fullscreen mode Exit fullscreen mode

Here's the same local state pattern in both approaches:

// ❌ Before: BehaviorSubject for local state
@Injectable({ providedIn: 'root' })
export class UserStore {
  private _users = new BehaviorSubject<User[]>([]);
  private _loading = new BehaviorSubject<boolean>(false);

  users$ = this._users.asObservable();
  loading$ = this._loading.asObservable();
  count$ = this._users.pipe(map(u => u.length)); // New observable per derivation

  setUsers(users: User[]): void {
    this._users.next(users);
  }

  setLoading(loading: boolean): void {
    this._loading.next(loading);
  }
}

// ✅ After: Signals for local state
@Injectable({ providedIn: 'root' })
export class UserStore {
  private readonly users = signal<User[]>([]);
  private readonly loading = signal(false);

  // Derived values — lazy, no subscription management
  readonly userList = this.users.asReadonly();
  readonly isLoading = this.loading.asReadonly();
  readonly count = computed(() => this.users().length);

  setUsers(users: User[]): void {
    this.users.set(users);
  }

  setLoading(loading: boolean): void {
    this.loading.set(loading);
  }
}
Enter fullscreen mode Exit fullscreen mode

No subscription management. No pipe(map(...)) for every derived value. No asObservable() to prevent external mutation. The signal version is shorter, more readable, and the computed() derivation is lazy by default.

effect() runs a side effect whenever its signal dependencies change, replacing subscribe() patterns for reactive side effects:

export class UserProfileComponent {
  readonly selectedUserId = signal<string | null>(null);

  constructor(private readonly userService: UserService) {
    // Runs once immediately, then re-runs whenever selectedUserId changes
    effect(() => {
      const id = this.selectedUserId();
      if (id) {
        this.userService.loadUser(id).subscribe(...);
      }
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Reserve RxJS Subjects for genuinely asynchronous, multi-consumer event streams where the operator ecosystem (debouncing, switching, combining) adds real value.


Architectural Recommendations

Default every component to OnPush. There is rarely a reason to use the Default strategy in a high-performance enterprise application. Make OnPush the standard in your lint rules and code review checklist, not an opt-in optimization.

Adopt signals for local and feature state. For state that drives the UI within a component or a feature library, signals outperform BehaviorSubjects: less boilerplate, lazy derived values, no subscription management. Keep RxJS for HTTP, WebSocket, and complex async orchestration.

Use the async pipe over manual subscriptions in templates. The async pipe handles markForCheck() for you in OnPush components, manages subscription lifecycle, and avoids memory leaks. Manual subscriptions in components with subscribe() require careful ngOnDestroy cleanup and don't integrate with OnPush automatically.

Minimize detectChanges(). If you find yourself calling it frequently, the underlying data flow is the problem — not change detection. Refactor toward reactive state (signals or async pipe) rather than adding more detectChanges() calls.


The Zoneless Future

Signals enable a zoneless application. With provideExperimentalZonelessChangeDetection() (Angular 18+), you can run Angular without Zone.js entirely:

// main.ts
bootstrapApplication(AppComponent, {
  providers: [
    provideExperimentalZonelessChangeDetection()
  ]
});
Enter fullscreen mode Exit fullscreen mode

In a zoneless application, change detection triggers only via signal changes and markForCheck() calls. No Zone.js patching, no detection triggered by timers or HTTP responses that don't affect signals. Angular renders exactly what needs to render, when it needs to render, because the signal dependency graph tells it precisely what changed.


Summary

The evolution of Angular change detection is a journey from implicit magic to explicit reactivity:

Zone.js (Default): Provided the ease of use that made Angular popular. Every async event triggers a full tree check. Correct for all cases, expensive at scale.

OnPush: Gave us the tools to scale performance through immutability and explicit check triggers. Requires discipline around references and the async pipe, but dramatically reduces unnecessary work in large component trees.

Signals: Provide the future-proof, fine-grained reactivity needed for the next generation of zoneless Angular applications. Angular renders exactly the components that depend on a changed signal, and nothing else.

By mastering these three pillars, you ensure your application remains fluid and responsive regardless of how large the component tree grows — and you position your codebase to take full advantage of where Angular is heading.

Top comments (1)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.