OnPush Components and the Change Detection Trigger Problem

This piece answers a question raised by Shai on Twitter about whether it’s a good idea to use the NgDoCheck lifecycle hook to manually compare values, rather than following the recommended practice of using the async pipe. It’s a fair question, and answering it properly requires a solid grasp of how Angular’s change detection, pipes, and lifecycle hooks interact behind the scenes.

I’ll walk through how to handle change detection manually, giving you finer-grained control over the comparisons Angular automatically performs for input bindings and async values. Once we’ve covered the mechanics, I’ll share my perspective on the performance trade-offs involved.

The Basics of OnPush Components

In Angular, a common performance optimization is to annotate a component with ChangeDetectionStrategy.OnPush. Consider a simple two-component hierarchy:

@Component({
    selector: 'a-comp',
    template: `
        <span>I am A component</span>
        <b-comp></b-comp>
    `
})
export class AComponent {}

@Component({
    selector: 'b-comp',
    template: `<span>I am B component</span>`
})
export class BComponent {}

Without any strategy specified, Angular will run change detection on both A and B components on every cycle. But if we apply the OnPush strategy to the B component:

@Component({
    selector: 'b-comp',
    template: `<span>I am B component</span>`,
    changeDetection: ChangeDetectionStrategy.OnPush
})
export class BComponent {}

Angular will only check B when its input bindings have changed. Since B has no bindings in this scenario, it will be checked only once during the initial bootstrap process.

Forcing Change Detection Manually

Is there a way to make Angular check the component anyway? Yes, by injecting changeDetectorRef and invoking its markForCheck method. This signals to Angular that the component should be checked. And because the NgDoCheck hook fires for B regardless of its strategy, that’s an ideal place to call it:

@Component({
    selector: 'b-comp',
    template: `<span>I am B component</span>`,
    changeDetection: ChangeDetectionStrategy.OnPush
})
export class BComponent {
    constructor(private cd: ChangeDetectorRef) {}

    ngDoCheck() {
        this.cd.markForCheck();
    }
}

With this, B will be checked whenever the parent A is checked. Let’s look at where this pattern comes in handy.

Handling Input Bindings

I mentioned that Angular only re-checks OnPush components when their bindings change. Let’s see that with an example. Suppose a parent passes an object down through an input:

@Component({
    selector: 'b-comp',
    template: `
        <span>I am B component</span>
        <span>User name: {{user.name}}</span>
    `,
    changeDetection: ChangeDetectionStrategy.OnPush
})
export class BComponent {
    @Input() user;
}

In the parent component A, we define the object and a method to update its name when a button is clicked:

@Component({
    selector: 'a-comp',
    template: `
        <span>I am A component</span>
        <button (click)="changeName()">Trigger change detection</button>
        <b-comp [user]="user"></b-comp>
    `
})
export class AComponent {
    user = {name: 'A'};

    changeName() {
        this.user.name = 'B';
    }
}

If you run this example, the initial change detection will display the user’s name:

User name: A

But when you click the button and the callback modifies the name:

changeName() {
    this.user.name = 'B';
}

the updated name does not show up on the screen. The reason is that Angular uses shallow comparison on inputs—it checks the reference, not the contents. The reference to the user object hasn’t changed, so no re-render occurs. How do we solve this?

We can perform a manual check on the name and trigger change detection only when we detect a difference:

@Component({
    selector: 'b-comp',
    template: `
        <span>I am B component</span>
        <span>User name: {{user.name}}</span>
    `,
    changeDetection: ChangeDetectionStrategy.OnPush
})
export class BComponent {
    @Input() user;
    previousName = '';

    constructor(private cd: ChangeDetectorRef) {}

    ngDoCheck() {
        if (this.previousName !== this.user.name) {
            this.previousName = this.user.name;
            this.cd.markForCheck();
        }
    }
}

If you try this code, the updated name will be reflected on the screen.

Dealing with Asynchronous Updates

Let’s add some complexity. We’ll introduce an RxJs-based service that emits updates asynchronously, similar to what you’d see in NgRx architectures. I’ll use a BehaviorSubject because it needs an initial value to start the stream:

@Component({
    selector: 'a-comp',
    template: `
        <span>I am A component</span>
        <button (click)="changeName()">Trigger change detection</button>
        <b-comp [user]="user"></b-comp>
    `
})
export class AComponent {
    stream = new BehaviorSubject({name: 'A'});
    user = this.stream.asObservable();

    changeName() {
        this.stream.next({name: 'B'});
    }
}

The child component receives this stream of user objects. We need to subscribe to the stream and verify that values are up to date. The standard approach for this scenario is the Async pipe.

The Async Pipe in Action

Here’s how the child component B is implemented with the pipe:

@Component({
    selector: 'b-comp',
    template: `
        <span>I am B component</span>
        <span>User name: {{(user | async).name}}</span>
    `,
    changeDetection: ChangeDetectionStrategy.OnPush
})
export class BComponent {
    @Input() user;
}

Check the demo. But is there an alternative that doesn’t rely on the pipe?

Manual Checking and Change Detection

Indeed, we can inspect the value and manually trigger change detection if needed. As seen in the earlier examples, the NgDoCheck lifecycle hook fits this purpose:

@Component({
    selector: 'b-comp',
    template: `
        <span>I am B component</span>
        <span>User name: {{user.name}}</span>
    `,
    changeDetection: ChangeDetectionStrategy.OnPush
})
export class BComponent {
    @Input('user') user$;
    user;
    previousName = '';

    constructor(private cd: ChangeDetectorRef) {}

    ngOnInit() {
        this.user$.subscribe((user) => {
            this.user = user;
        })
    }

    ngDoCheck() {
        if (this.previousName !== this.user.name) {
            this.previousName = this.user.name;
            this.cd.markForCheck();
        }
    }
}

You can experiment with it here.

Ideally, though, we’d move the comparison and update logic out of NgDoCheck and into the subscription callback, since that’s where the fresh value arrives:

export class BComponent {
    @Input('user') user$;
    user = {name: null};

    constructor(private cd: ChangeDetectorRef) {}

    ngOnInit() {
        this.user$.subscribe((user) => {
            if (this.user.name !== user.name) {
                this.cd.markForCheck();
                this.user = user;
            }
        })
    }
}

Test it here.

Interestingly, this is precisely what the Async pipe does under the hood:

@Pipe({name: 'async', pure: false})
export class AsyncPipe implements OnDestroy, PipeTransform {
  constructor(private _ref: ChangeDetectorRef) {}

  transform(obj: ...): any {
    ...
    this._subscribe(obj);

    ...
    if (this._latestValue === this._latestReturnedValue) {
      return this._latestReturnedValue;
    }

    this._latestReturnedValue = this._latestValue;
    return WrappedValue.wrap(this._latestValue);
  }

  private _subscribe(obj): void {
    ...
    this._strategy.createSubscription(
        obj, (value: Object) => this._updateLatestValue(obj, value));
  }

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

Which Approach Is Quicker?

Now that we’ve seen how to replace the async pipe with manual change detection, let’s address the original question: which is faster?

The answer depends on your metrics, but assuming all else is equal, the manual approach tends to be faster. That said, I doubt you’d notice the difference in practice. Here’s a few reasons why the manual path can edge ahead.

Memory-wise, you avoid instantiating a Pipe class. During compilation, the compiler skips parsing pipe-specific syntax and generating the associated code. At runtime, you save a handful of function calls for each change detection cycle. Below is the updateRenderer function generated for the pipe-based code:

function (_ck, _v) {
    var _co = _v.component;
    var currVal_0 = jit_unwrapValue_7(_v, 3, 0, asyncpipe.transform(_co.user)).name;
    _ck(_v, 3, 0, currVal_0);
}

Notice how the async pipe version invokes the transform method on the pipe instance to pull the latest value, which the pipe returns from its subscription.

Now compare that to the plain generated code for the manual approach:

function(_ck,_v) {
    var _co = _v.component;
    var currVal_0 = _co.user.name;
    _ck(_v,3,0,currVal_0);
}

These are the functions Angular executes when checking the B component.

A Couple of Fascinating Details

Unlike input bindings, which use shallow comparison, the async pipe skips comparison entirely. It treats every new emission as an update, even when the value matches the previous one. Look at this parent component A that emits the same object—Angular still runs change detection on B:

export class AComponent {
    o = {name: 'A'};
    user = new BehaviorSubject(this.o);

    changeName() {
        this.user.next(this.o);
    }
}

This implies that any component using the async pipe will be marked for check each time a value is emitted. Consequently, Angular will check that component on the next change detection cycle, even if the value hasn’t actually changed.

Why does this matter? In our case, the template only cares about the name property from the user object. The object’s reference changing doesn’t interest us. If the name stays the same, a re-render is unnecessary. Unfortunately, the async pipe doesn’t give you that level of control.

On the flip side, NgDoCheck isn’t without its own caveats. Since the hook only fires when the parent is checked, it won’t trigger if an ancestor uses OnPush and gets skipped during change detection. For service-based updates, you can’t depend on it to flag the component for a check. In those situations, the fix I demonstrated—placing markForCheck inside the subscription callback—is the safer route.

Final Thoughts

Essentially, manual comparison offers you more authority over the checking process. It’s up to you to decide when a component needs attention. This is typical of working with lower-level tools—manual control affords greater flexibility, but it demands a clear understanding of what you’re doing. To build that knowledge, I’d recommend investing time in studying the source code.

If your worry is that NgDoCheck runs too often, or more frequently than the pipe’s transform method—don’t sweat it. First, in the manual async-stream example above, I showed a solution that doesn’t use the hook at all. Second, the hook only executes when the parent component is checked; if the parent is skipped, the hook doesn’t fire. And regarding the pipe, due to shallow checks and changing references in the stream, the transform method will likely be called just as often, if not more.

Dive Deeper into Angular Change Detection

Kick off with These 5 articles will make you an Angular Change Detection expert. This sequence is essential reading for anyone wanting to truly understand Angular’s change detection system. Each installment builds on the previous one, moving from a high-level overview down to the nitty-gritty implementation details, complete with source references.