Angular OnPush – A Change Detection Strategy Revealing Mistakes in Your Code

When optimizing the performance of Angular applications, many developers directly associate Angular's OnPush change detection strategy with it. But, if you don't know exactly how OnPush works under the hood, it will quickly teach you what you are doing wrong the hard way. In this article, we'll look deeper into how the OnPush strategy affects Angular's change detection mechanism and which pitfalls we should avoid at all costs.

In this article:

SL-rund
Sascha Lehmann is Developer at Thinktecture. He focusses on web-based frontends with Angular and User Experience improvement.
All examples and code references refer to Angular 14.2.2. Also, note that all explanations only apply to Angular versions based on Ivy.
At Thinktecture, we support our customers with various problems in our daily work. Of course, the topic of performance optimization is always on the agenda. Many developers from the Angular environment associate “performance optimization” directly with OnPush. Accordingly, OnPush is often used in many projects right from the start. But OnPush doesn’t automatically guarantee to skyrocket your application’s performance. If you lack knowledge of what it does under the hood, it can quickly turn into the opposite. It can lead to unexpected problems, which may only become apparent much later in the project and are not easy to detect. To help you avoid these pitfalls right from the start, we’ll first dive into Angular’s code to look at what OnPush does with the change detection mechanism and then head into some examples.
Before we dig into the internals of OnPush, you should make sure that you have a general understanding of what change detection is, why we need it and how Angular handles it. If you need a quick refresh or a basic introduction to the topic, I highly suggest looking at the article What’s the hype with OnPush, by my colleague Max Marschall first.

Component = View

As we all know, our components in Angular form a hierarchical tree structure. However, the change detection is not executed on the component but on a low-level abstraction layer called View or, more precisely,  LView (the term “view” is used interchangeably with the word “component” in the following). A view is directly associated with an instance of a component and contains additional state information, the LViewFlags. These flags are significant to decide whether a change detection cycle for the view and all its children will be skipped or not. The most important property here is CheckAlways
Note: As you might have noticed, the flags represent specific bits of a number. This mechanism performs well when storing multiple flags in a single value.

When no CheckAlways flag is present, Angular will skip the change detection checks of the view and all its children. And this is where ChangeDetectionStrategy comes into play.

Angular's two change detection strategies

The default of CheckAlways corresponds to Angular’s ChangeDetectionStrategy.Default. Here, Angular starts at the top view and recursively applies the check and update process for all child views, the tree downwards.
So ChangedetectionStrategy.OnPush must therefore unset the CheckAlways flag, right? It is not quite that simple. According to the comments in the Angular code, the following happens:
Use the `CheckOnce` strategy, meaning that automatic change detection is deactivated until reactivated by setting the strategy to `Default` (`CheckAlways`).
Change detection can still be explicitly invoked. This strategy applies to all child directives and cannot be overridden. (Angular code here)
We can confirm that quote by looking inside Angular’s ComponentFactory class (Angular code here). With OnPush, the view is marked as Dirty for checking it once; otherwise, CheckAlways is the default.
				
					const rootFlags = this.componentDef.onPush 
                  ? LViewFlags.Dirty | LViewFlags.IsRoot 
                  : LViewFlags.CheckAlways | LViewFlags.IsRoot;

				
			
However, if the @Input of the component changes, Angular will schedule checks again. The setInput function of the ComponentRef handles this. A function markDirtyIfOnPush gets called and sets the Dirty Flag on the view, so it gets checked.
				
					export function markDirtyIfOnPush(lView: LView, viewIndex: number): void {
  ngDevMode && assertLView(lView);
  const childComponentLView = getComponentLViewByIndex(viewIndex, lView);
  if (!(childComponentLView[FLAGS] & LViewFlags.CheckAlways)) {
    childComponentLView[FLAGS] |= LViewFlags.Dirty;
  }
}

				
			
At this point, we should look at the three scenarios when an OnPush component triggers a change detection:
 
  • When an @Input changes
  • When an @Output triggers 
  • When the markForCheck function gets called
So what can we take away from this? We saw that OnPush is not a performance tool per se; it is just a different strategy for Angular’s change detection that can help to reduce unnecessary change detection cycles, which may result in better performance. Especially within larger applications with many components, but it isn’t a guaranteed performance booster for every application.
 
After all this dry theory, it is time for some practical examples.

Always do it the "Angular way"

During my daily work, I stumbled over a git repository, which implements a graphical data picker component. It is an excellent example of how OnPush can show you your mistakes. I created an Angular application with the mentioned data picker in the following example (I took the code from the repository and adapted it a bit to fit the current Angular version; please feel free to play around with it).
Using ChangeDetectionStrategy.Default (which is the default), everything works smoothly. But the component seems broken when we set it to OnPush (like in the example below). Typically, the data wheel should rotate and lock when released. But what is the reason for this?
When we look at the components code, we are even more confused. There are several event handlers reacting to mouse events. When we interact with the wheel and look at the console, we see that the events are registered and doing their work.
As a result of what we have learned, a change detection cycle should trigger. But why was it not?
The error is basically where the EventHandler registration takes place. When we look at the code, we can see that the handlers directly get registered on the element with addEventListener. As a result, Angular doesn’t know that the events raise in this particular component. So, it cannot set the Dirty flag on the view and will skip the component (and its children) from change detection
				
					  addEventsForElement(el): void {
    const _ = this.touchOrMouse.isTouchable;
    const eventHandlerList = [
      { name: _ ? 'touchstart' : 'mousedown', handler: this.handleStart },
      { name: _ ? 'touchmove' : 'mousemove', handler: this.handleMove },
      { name: _ ? 'touchend' : 'mouseup', handler: this.handleEnd },
      {
        name: _ ? 'touchcancel' : 'mouseleave',
        handler: this.handleCancel,
      },
    ];

    eventHandlerList.forEach((item, index) => {
      el.removeEventListener(item.name, item.handler, false);
      el.addEventListener(item.name, item.handler.bind(this), false);
    });
  }
				
			
But how can we solve the problem? In this case, it is pretty simple. We need to register the events in the „Angular way“ instead. In this example, we should do this via template binding. Thus, the framework can map the event to the component and mark it to be checked, although using OnPush.
This small example shows us how important it is to solve our problems with the tools that Angular provides and how OnPush has shown us our mistakes that we possibly wouldn’t have discovered with the default strategy.

Mutable Objects and OnPush don't like each other

Let’s have a look at another example. A presentational component with OnPush change detection receives a person object as an input parameter. After the startup, the object displays correctly; so far, so good. Then we change a value in the form. But what do we see? Nothing happens. The child component doesn’t display the change correctly. But when we check the output in the console, everything should work correctly.
The simple reason is that OnPush-based components compare their input parameters via object comparison (Object.is(), Angular code here). In the previous case, the object itself did not change; instead, the code mutated only an object member.
				
					mutatePerson() {
    this.person.name = this.form.value.name;
    this.person.lastName = this.form.value.lastName;
    this.person.age = this.form.value.age;
    console.log('Person mutated values: ', this.person);
}
				
			
The @Input registers no change and doesn’t set the Dirty flag for the next cycle. Exactly such problem constellations can often lead to unintentionally sprawling debugging sessions. To avoid these from the outset, you should use an immutable state or immutable objects. Doing changes on immutable objects means that you don’t modify any properties but always create a new object with the changed values. It is also mandatory to treat your objects this way when using any store pattern or store-related library like ngrx.
If you are also interested in this topic, check out the articles and webinars of my colleague Yannick Baron.
Last but not least, how do we fix our example? As mentioned before, we need to replace the person object with a new one. And everything works as expected.
				
					mutatePerson() {
    this.person = this.form.value;
    console.log('Person mutaded values: ', this.person);
}
				
			

Conclusion

Finally, let’s briefly summarize our learnings. We saw that OnPush is not a performance tool per se. It just changes the strategy of Angular handling the change detection cycles. When using OnPush, we need to take the responsibility of knowing when the view actually gets updated and possibly need to update it manually. As a result, it rewards us with reduced change detection cycles and, therefore, may increase the performance of our applications. But be aware that OnPush also mercilessly shows us our mistakes, which may result in some headaches. But after you read this article now, these headaches should be history by now. To avoid them even better, follow the most important rule of performance optimization:
 
Don’t worry about optimization if you are dealing with smaller applications or not encountering issues like stuttering or frame drops.
And if you’re interested in the best possible performance from the start, keep in mind what we’ve just learned.
Free
Newsletter

Current articles, screencasts and interviews by our experts

Don’t miss any content on Angular, .NET Core, Blazor, Azure, and Kubernetes and sign up for our free monthly dev newsletter.

EN Newsletter Anmeldung (#7)
Related Articles
Angular
SL-rund
If you previously wanted to integrate view transitions into your Angular application, this was only possible in a very cumbersome way that needed a lot of detailed knowledge about Angular internals. Now, Angular 17 introduced a feature to integrate the View Transition API with the router. In this two-part series, we will look at how to leverage the feature for route transitions and how we could use it for single-page animations.
15.04.2024
.NET
KP-round
.NET 8 brings Native AOT to ASP.NET Core, but many frameworks and libraries rely on unbound reflection internally and thus cannot support this scenario yet. This is true for ORMs, too: EF Core and Dapper will only bring full support for Native AOT in later releases. In this post, we will implement a database access layer with Sessions using the Humble Object pattern to get a similar developer experience. We will use Npgsql as a plain ADO.NET provider targeting PostgreSQL.
15.11.2023
.NET
KP-round
Originally introduced in .NET 7, Native AOT can be used with ASP.NET Core in the upcoming .NET 8 release. In this post, we look at the benefits and drawbacks from a general perspective and perform measurements to quantify the improvements on different platforms.
02.11.2023