Zone.js Is Not the Engine of Change Detection

Beginners are often told that without zone.js, change detection would grind to a halt, leaving the UI permanently stale. While this statement holds in a basic sense, it misses a key distinction: zone.js merely *enables* change detection—it doesn't perform the work itself.

Here's how the mechanism actually unfolds:

  1. Angular refreshes the view only when the application state has changed.
  2. Angular correctly assumes that state mutations can occur **only** in response to asynchronous activity—a click, a completed network request, a firing timer, etc.
  3. zone.js intercepts and patches all browser async APIs, allowing them to emit notifications.
  4. Angular subscribes to these notifications. When one arrives, it kicks off a top-down change detection pass, comparing component data and updating the DOM where necessary.

In short, zone.js alerts Angular: "Something async just happened, so state might have changed." Angular then performs the checks to see if anything *actually* did.

Note: this is a simplified account—change detection runs also handle other tasks, but those are beyond this discussion.

Change detection, therefore, is a process with no inherent dependency on zone.js. For instance, you can trigger it manually via ChangeDetectorRef:

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

@Component({
  selector: 'app-root',
  template: `
    <h1>My App</h1>
    <p>Counter: {{ counter }}</p>
    <button (click)="increment()">Increment</button>
  `
})
export class AppComponent {
  private readonly cdRef = inject(ChangeDetectorRef);
  counter = 0;

  increment() {
    this.counter++;
    this.cdRef.detectChanges();
  }
}
Enter fullscreen mode Exit fullscreen mode

Even if we strip zone.js from the polyfills array in angular.json and configure {ngZone: 'noop'} in the bootstrapModule options of main.ts, an application can still function—provided it handles its own detection triggers. Consider the source of Angular's async pipe:


@Pipe({
  name: 'async',
  pure: false,
  standalone: true,
})
export class AsyncPipe implements OnDestroy, PipeTransform {
  private _ref: ChangeDetectorRef|null;
  private _latestValue: any = null;
  private _obj: Subscribable<any>|Promise<any>|EventEmitter<any>
   |null = null;

  constructor(private ref: ChangeDetectorRef) { }

  transform<T>(
    obj: Observable<T>|Subscribable<T>|Promise<T>|null|undefined,
  ): T|null {
    if (!this._obj) {
      if (obj) {
        this._subscribe(obj);
      }
      return this._latestValue;
    }

    if (obj !== this._obj) {
      this._dispose();
      return this.transform(obj);
    }

    return this._latestValue;
  }

  private _subscribe(
    obj: Subscribable<any>|Promise<any>|EventEmitter<any>
  ): void {
    // this method performs subscriptions to
    // Observable or Promise
    // and then calls _updateLatestValue
    // omitted for brevity
  }

  private _updateLatestValue(async: any, value: Object): void {
    if (async === this._obj) {
      this._latestValue = value;
      this._ref!.markForCheck();
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The transform method itself is minimal: it subscribes if needed and returns the latest value. The real work is in _subscribe, which sets up the subscription and calls _updateLatestValue upon each emission. That method, in turn, invokes markForCheck on ChangeDetectorRef. The async pipe, then, bypasses zone.js entirely, requesting change detection on its own.

So, if your app’s reactivity relied solely on RxJS Observables consumed through the async pipe, you could theoretically ship it without zone.js and it would still update correctly.

Note: the code above is a simplification of the actual async pipe implementation; see the full source here.

Another note: with Angular's Signals on the horizon, this topic may evolve significantly.

OnPush Does More Than Check Inputs

A surprisingly common belief among Angular developers—one I've encountered in roughly 90% of interviews I've conducted—is that the OnPush change detection strategy only reacts to input changes. A simple demonstration proves otherwise:

In this example, the ChildComponent has a text property initially set to "some text". It has no inputs and uses OnPush. Clicking the button updates the property to "other text", and the UI reflects this change without issue.

So what does ChangeDetectionStrategy.OnPush actually accomplish?

  1. It disables the deep-checking that skips reading nested object properties. If an OnPush component receives an object as an input and that object is mutated in place, the component won't re-render (at least not readily). To ensure a UI update, you'd typically provide a new object reference. Consider this:

Here, we have an AppComponent parent and a ChildComponent that accepts an array as an Input. The parent offers two buttons: one to push to the array directly and one to replace the array reference. Only clicking the second button updates the UI. The first button—modifying the existing array—leads to no visible change, as OnPush compares input references, not values.

  1. The rules shift when the interaction originates within the component itself. If we click a button *inside* the child that pushes data into the array, the UI updates immediately, because local events within an OnPush component also trigger a change detection cycle.
  2. Changes aren't lost. If we click the parent's "Push to array" button several times and *then* click the child's "Push to array" button, we'll see all accumulated items appear. The mutations were there—they just weren't detected until the child triggered a check.

There are additional subtleties, which are explored in detail here. But the takeaway is clear: OnPush is not exclusively about inputs or referential equality.

Calling Methods in Templates Isn't Inherently Wrong

The common refrain is to **NEVER** call methods in Angular templates. The reasoning: if a method returns a value, Angular must invoke it to retrieve that value and determine whether the UI needs updating, which could be expensive. The key word here is *expensive*. What about inexpensive computations?

Compare these two template expressions:

@Component({
  template: `
    {{a + b}}
  `,
})
export class AppComponent {
  a = 1;
  b = 2;
}
Enter fullscreen mode Exit fullscreen mode
@Component({
  template: `
    {{sum()}}
  `,
})
export class AppComponent {
  a = 1;
  b = 2;

  sum() {
    return this.a + this.b;
  }
}
Enter fullscreen mode Exit fullscreen mode

Both yield the same result with minimal computation. In fact, Angular performs the same work in both cases; the only difference is the method call, which adds a negligible overhead—essentially just the cost of pushing a function onto the call stack.

Another often-overlooked fact: many properties we regularly use in templates are themselves getters—functions in disguise. Take FormControl.valid, for example. It's actually a getter that returns the result of a simple calculation, just like any other form-related property.

So, what's the actual guideline?

  1. The only hard rule is to avoid heavy operations in template-callable methods—no large array iterations, no API requests, nothing that could cause a noticeable delay. Cheap calculations are perfectly acceptable.

Note: once Signals are stable, a computed property will be the recommended way to avoid redundant function executions altogether.

Impure Pipes Aren't a Sin

This point follows naturally from the previous one. By default, all pipes are pure—they only execute when their inputs change. Impure pipes, in contrast, run on every change detection cycle. But, as before, the determining factor should be the cost of the calculation. Sometimes, you genuinely need an impure pipe, particularly when working with reactive data. Some built-in pipes are already impure, most notably the async pipe. Accordingly, using impure pipes is not inherently bad practice.

5. Treating an EventEmitter like a Subject

Among the first patterns Angular developers encounter is the EventEmitter—it’s the go‑to tool for parent‑child communication. A quick look at the source code shows that it subclasses RxJS Subject, and in practice you can call emit, subscribe to it, and otherwise treat it as an ordinary Observable. However, that flexibility is not a promise. The Angular team has not committed to keeping EventEmitter as an Observable, so relying on that behavior means your code could break in a future release. In fact, with the arrival of signals, there is already active discussion about reworking how components emit events.

6. Choosing Reactive Forms for every form

Reactive Forms offer a robust feature set, and many of their capabilities are genuinely useful. That said, they introduce a more complex mental model than the simplicity of NgModel, and that added complexity can sometimes slow down development. A more pragmatic approach is to start with template‑driven forms for straightforward cases—like those without intricate validation—and reserve Reactive Forms for scenarios that genuinely require their power.

Wrapping Up

Angular offers a deep and varied ecosystem with no shortage of lessons to learn. As demonstrated here, the community is full of conflicting advice, and this article aimed to clear up a few of those points. If you have come across other common Angular misconceptions or misuses, feel free to leave them in the comments below.