Deep dive into the OnPush change detection strategy in Angular

Max Koretskyi
Angular In Depth
Published in
11 min readFeb 20, 2023

--

This article is an excerpt from my Angular Deep Dive course series. The course series explains the most complicated parts of Angular. It doesn’t teach syntax or reiterate documentation; instead, it teaches fundamental concepts. All this packed with the unique information on the internal design of this mechanism.

Angular implements two strategies that control change detection behavior on the level of individual components. Those strategies are defined as Default and OnPush:

export enum ChangeDetectionStrategy {
OnPush = 0,
Default = 1
}

Angular uses these strategies to determine whether a child component should be checked while running change detection for a parent component. A strategy defined for a component impacts all child directives since they are checked as part of checking the host component. A defined strategy cannot be overridden in runtime.

The default strategy, internally referred to as CheckAlways, implies regular automatic change detection for a component unless the view is explicitly detached. What’s known as OnPush strategy, internally referred to as CheckOnce, implies that change detection is skipped unless a component is marked as dirty. Angular implements mechanisms to automatically mark a component as dirty. When needed, a component can be marked dirty manually using markForCheck method exposed on ChangeDetectorRef.

When we define a strategy using @Component() decorator, Angular’s compiler records it in a component’s definition through the defineComponent function. For example, for the component like this:

@Component({
selector: 'a-op',
template: `I am OnPush component`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class AOpComponent {}

the definition generated by the compiler looks like this:

When Angular instantiates a component, it’s using this definition to set a corresponding flag on the LView instance that represents the component’s view:

This means that all LView instances created for this component will have either CheckAlways or Dirty flag set. For the OnPush strategy the Dirty flag will be automatically unset after the first change detection pass.

The flags set on LView are checked inside the refreshView function when Angular determines whether a component should be checked:

function refreshComponent(hostLView, componentHostIdx) {
// Only attached components that are CheckAlways or OnPush and dirty
// should be refreshed
if (viewAttachedToChangeDetector(componentView)) {
const tView = componentView[TVIEW];
if (componentView[FLAGS] & (LViewFlags.CheckAlways | LViewFlags.Dirty)) {
refreshView(tView, componentView, tView.template, componentView[CONTEXT]);
} else if (componentView[TRANSPLANTED_VIEWS_TO_REFRESH] > 0) {
// Only attached components that are CheckAlways
// or OnPush and dirty should be refreshed
refreshContainsDirtyView(componentView);
}
}
}

Let’s now explore those strategies in greater detail.

Default strategy

Default change detection strategy means that a child component will always be checked if its parent component is checked. The only exception to that rule is that if you detach a change detector of the child component like this:

@Component({
selector: 'a-op',
template: `I am OnPush component`
})
export class AOpComponent {
constructor(private cdRef: ChangeDetectorRef) {
cdRef.detach();
}
}

Note that I specifically highlighted the part about the parent component being checked. If a parent component isn’t checked, Angular won’t run change detection for the child component even if it uses default change detection strategy. This comes from the fact that Angular runs check for a child component as part of checking its parent.

Angular doesn’t enforce any workflows on developers for detecting when a component’s state changed, that’s why the default behavior is to always check components. An example of the enforced workflow is object immutability that’s passed through @Input bindings. This is what’s used for OnPush strategy and we’ll explore it next.

Here we have a simple hierarchy of two components:

@Component({
selector: 'a-op',
template: `
<button (click)="changeName()">Change name</button>
<b-op [user]="user"></b-op>
`,
})
export class AOpComponent {
user = { name: 'A' };

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

@Component({
selector: 'b-op',
template: `<span>User name: {{user.name}}</span>`,
})
export class BOpComponent {
@Input() user;
}

When we click on the button, Angular runs an event handler where we update the user.name. As part of running subsequent change detection loop, the child B component is checked and the screen is updated:

While the reference to the user object hasn’t changed, it has been mutated inside but we can still see the new name rendered on the screen. That is why the default behavior is to check all components. Without the object immutability restriction in place Angular, can’t know if inputs have changed and caused an update to the component's state.

OnPush aka CheckOnce strategy

While Angular does not force object immutability on us, it gives us a mechanism to declare a component as having immutable inputs to reduce the number of times a component is checked. This mechanism comes in the form of OnPush change detection strategy and is a very common optimization technique. Internally this strategy is called CheckOnce, as it implies that change detection is skipped for a component until it’s marked as dirty, then checked once, and then skipped again. A component can be marked dirty either automatically or manually using markForCheck method.

Let’s take an example from above and declare OnPush change detection strategy for the B component:

@Component({...})
export class AOpComponent {
user = { name: 'A' };

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

@Component({
selector: 'b-op',
template: `
<span>User name: {{user.name}}</span>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class BOpComponent {
@Input() user;
}

When we run the application Angular doesn’t pick a change in a user.name anymore:

You can see that B component is still checked once during the bootstrap - it renders the initial name A. But it’s not checked during the subsequent change detection runs, so you don’t see the name changed from A to B when clicked on the button. That happens because the reference to the user object that’s passed down to B component through @Input hasn’t changed.

Before we take a look at the different ways a component can be marked as dirty, here’s the list of different scenarios that Angular uses to test OnPush behavior:

should skip OnPush components in update mode when they are not dirty
should not check OnPush components in update mode when parent events occur

should check OnPush components on initialization
should call doCheck even when OnPush components are not dirty
should check OnPush components in update mode when inputs change
should check OnPush components in update mode when component events occur
should check parent OnPush components in update mode when child events occur
should check parent OnPush components when child directive on a template emits event

The last batch of test scenarios ensures that automatic process of marking a component dirty occurs in the following scenarios:

  • an @Input reference is changed
  • a bound event is received triggered on the component itself

Let’s now explore these.

@Input bindings

In most situations we will need to check the child component only when its inputs change. This is especially true for pure presentational components whose input comes solely through bindings.

Let’s take a previous example:

@Component({...})
export class AOpComponent {
user = { name: 'A' };

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

@Component({
selector: 'b-op',
template: `
<span>User name: {{user.name}}</span>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class BOpComponent {
@Input() user;
}

As we have seen above, when we click on the button and change the name in the callback, the new name is not updated on the screen. That’s because Angular performs shallow comparison for the input parameters and the reference to the user object hasn’t changed. Mutating an object directly doesn’t result in a new reference and won’t mark the component dirty automatically.

We have to change the reference for the user object for Angular to detect the difference in @Input bindings. If we create a new instance of user instead of mutating the existing instance, everything will work as expected:

@Component({...})
export class AOpComponent {
user = { name: 'A' };

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

Yep, all good:

You could easily enforce the immutability on objects with a recursive Object.freeze implementation:

export function deepFreeze(object) {
const propNames = Object.getOwnPropertyNames(object);

for (const name of propNames) {
const value = object[name];
if (value && typeof value === 'object') {
deepFreeze(value);
}
}

return Object.freeze(object);
}

So that when somebody tries to mutate the object it’ll throw the error:

Probably the best approach is to use a specialized library like immer:

import { produce } from 'immer';

@Component({...})
export class AOpComponent {
user = { name: 'A' };

changeName() {
this.user = produce(this.user, (draft) => {
draft.name = 'B';
});
}
}

This will work fine as well.

Bound UI events

All native events, when triggered on a current component, will mark dirty all ancestors of the component up to the root component. The assumption is that an event could trigger change in the components tree. Angular doesn’t know if the parents will change or not. That is why Angular always checks every ancestor component after an event has been fired.

Imagine you have a component tree hierarchy like this of OnPush components:

AppComponent
HeaderComponent
ContentComponent
TodoListComponent
TodoComponent

If we attach an event listener inside the TodoComponent template:

@Component({
selector: 'todo',
template: `
<button (click)="edit()">Edit todo</button>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class TodoComponent {
edit() {}
}

Angular marks dirty all ancestor components before it runs the event handler:

Hence the hierarchy of components is marked for the check once looks like this:

Root Component -> LViewFlags.Dirty
|
...
|
ContentComponent -> LViewFlags.Dirty
|
|
TodoListComponent -> LViewFlags.Dirty
|
|
TodoComponent (event triggered here) -> markViewDirty() -> LViewFlags.Dirty

During the next change detection cycle, Angular will check the entire tree of TodoComponent's ancestor components:

AppComponent (checked)
HeaderComponent
ContentComponent (checked)
TodosComponent (checked)
TodoComponent (checked)

Note that the HeaderComponent is not checked because it is not an ancestor of the TodoComponent.

Manually marking components as dirty

Let’s come back to the example where we changed the reference to the user object when updating the name. This enabled Angular to pick up the change and mark B component as dirty automatically. Suppose we want to update the name but don’t want to change the reference. In that case, we can mark the component as dirty manually.

For that we can inject changeDetectorRef and use its method markForCheck to indicate for Angular that this component needs to be checked:

@Component({...})
export class BOpComponent {
@Input() user;

constructor(private cd: ChangeDetectorRef) {}

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

What can we use for someMethodWhichDetectsAndUpdate? The NgDoCheck hook is a very good candidate. It's executed before Angular will run change detection for the component but during the check of the parent component. This is where we'll put the logic to compare values and manually mark component as dirty when detecting the change.

The design decision to run NgDoCheck hook even if a component is OnPush often causes confusion. But that’s intentional and there's no inconsistency if you know that it's run as part of the parent component check. Keep in mind that ngDoCheck is triggered only for top-most child component. If the component has children, and Angular doesn't check this component, ngDoCheck is not triggered for them.

Don’t use ngDoCheck to log the checking of the component. Instead, use the accessor function inside the template like this {{ logCheck() }}.

So let’s introduce our custom comparison logic inside the NgDoCheck hook and mark the component dirty when we detect the change:

@Component({...})
export class AOpComponent {...}

@Component({
selector: 'b-op',
template: `
<span>User name: {{user.name}}</span>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class BOpComponent {
@Input() user;
previousUserName = '';

constructor(private cd: ChangeDetectorRef) {}

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

Remember that markForCheck neither triggers nor guarantees change detection run. See the chapter on manual control for more details.

Observables as @Inputs

Now, let’s make our example a bit more complex. Let’s assume our child B component takes an observable based on RxJs that emits updates asynchronously. This is similar to what you might have in NgRx based architecture:

@Component({
selector: 'a-op',
template: `
<button (click)="changeName()">Change name</button>
<b-op [user$]="user$.asObservable()"></b-op>
`,
})
export class AOpComponent {
user$ = new BehaviorSubject({ name: 'A' });

changeName() {
const user = this.user$.getValue();
this.user$.next(
produce(user, (draft) => {
draft.name = 'B';
})
);
}
}

So we receive this stream of user objects in the child B component. We need to subscribe to the stream, check if the value is updated and mark the component as dirty if needed:

@Component({
selector: 'b-op',
template: `
<span>User name: {{user.name}}</span>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class BOpComponent {
@Input() user$;
user = null;

constructor(private cd: ChangeDetectorRef) {}

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

The logic inside the ngOnChanges is almost exactly what the async pipe is doing:

export class AsyncPipe {
transform() {
if (obj) {
this._subscribe(obj);
}
}

private _updateLatestValue(async, value) {
if (async === this._obj) {
this._latestValue = value;
this._ref!.markForCheck();
}
}
}

That’s why the common approach is to delegate the subscription and comparison logic to async pipe. The only restriction is that objects must be immutable.

Here’s the implementation of the child B component that uses async pipe:

@Component({
selector: 'b-op',
template: `
<span>User name: {{(user$ | async).name}}</span>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class BOpComponent {
@Input() user$;
}

There are a bunch of test cases that test async pipe and its interaction with various types:

describe('Observable', () => {
describe('transform', () => {
it('should return null when subscribing to an observable');
it('should return the latest available value');
it('should return same value when nothing has changed since the last call');
it('should dispose of the existing subscription when subscribing to a new observable');
it('should request a change detection check upon receiving a new value');
it('should return value for unchanged NaN');
});
});
describe('Promise', () => {...});
describe('null', () => {...});
describe('undefined', () => {...});
describe('other types', () => {...});

This test is for the use case we explored here:

it('should request a change detection check upon receiving a new value', done => {
pipe.transform(subscribable);
emitter.emit(message);

setTimeout(() => {
expect(ref.markForCheck).toHaveBeenCalled();
done();
}, 10);
});

The pipe subscribes to the observable inside the transform, and when the observable emits a new message, it marks the component as dirty.

in depth knowledge we trust

--

--

Max Koretskyi
Angular In Depth

Principal Engineer at kawa.ai . Founder of indepth.dev. Big fan of software engineering, Web Platform & JavaScript. Man of Science & Philosophy.