Leverage Angular's change detection mechanism to speed up your app

16
minutes
Mis à jour le
31/3/2023

Share this post

This article will introduce you to Angular’s Change Detection mechanism and how you can leverage it to optimize your app's performance.

#
Angular
#
Change Detection
#
Front-end
#
OnPush
#
Performance
#
Zone.js
Mahdi Lazraq
Software Engineer

Introduction

The path that led me to writing this post is one that is pretty common in the lifecycle of a software engineer.

You join a new project that is not based on the stack you are most used to. The first tickets you take responsibility of are manageable thanks to the cross-framework knowledge and skills you acquired during your career but then one day, one ticket is unexpectedly painful as it involves a concept that is very specific to the project’s stack.

In my case, the project was Angular-based and I couldn’t get my head around why the changes I made to the component weren’t reflected on the UI. It all came down to one particular line that had me scratching my head for hours (days) ⬇️

Capture d’écran 2023-03-10 à 10.11.11

In an attempt to preserve your scalp, I will introduce you to Angular’s Change Detection mechanism and the ABCs of improving its performance.

Why does Angular (or any frontend framework) need a change detection mechanism ?

This post section comes down to the following quote by ChatGPT ⬇️ but let me expand on it a little bit.

Change Detection is a crucial aspect of Angular, as it determines when and how the component's view should be updated. It allows Angular to keep track of any changes in component data and re-render the view accordingly, ensuring that the user interface remains up-to-date with the latest data.

The underlying contract between Angular and a developer using it stipulates the following transaction :

  • As a developer, I will give you a model (application state)
  • As Angular, I will transform it into a user interface.

In the case of a static application, this is a fairly easy job for Angular.

In the example below, the application state simply consists of a Company with its properties. We give that (and a template) to Angular, and it renders it for us into this beautiful modern user interface.

1

Let’s now imagine that we add a functionality to our application that consists in changing the company’s name when the user clicks on the corresponding button. Each time a user clicks on that button, the state of our application changes and Angular needs to project this new data into an updated UI.

That is when it can get tricky : how is Angular supposed to know that a user has clicked on the button, and that the state has changed ?

Before going into that, let’s notice that the action of clicking on a button is an asynchronous operation. Below are a few other examples of asynchronous operations :

  • User events : clicking on a button, filling-out an input, submitting a form, etc.
  • Timers : setTimeout(), setInterval(), etc.
  • XMLHttpRequest (XHR) resolving - it is a web API that allows web browsers to make HTTP requests to a server and retrieve data from it without having to reload the page (you use it every day, you just might not know it is called like that)

Basically, anytime one of the aforementioned operations are performed on our application, it may cause its state to change, and someone (something) has to convey this information to Angular so it can update the associated views.

Zone.js

The culprit is the Zone.js library and its Zones. I am not going to dive deep into that subject (here is a talk by Brian Ford that helped me get the grasp of it), but I will tell you what you need to know in the context of this blog post.

A zone is basically an execution context for your code. You can think of it as a room. This room is equipped with a detection system that captures the events that happen inside it, and reacts to them according to the rules and behaviors you previously have written for it. For example, whenever it detects that you entered the room, it will turn the lights on. Whenever you sit on your couch, it will turn on the TV and set it on your favorite Youtube channel. This works the same for every room in your house, according to the rules you specifically have set for each of those rooms.

Angular does exactly that. When the application starts, Angular creates its own zone instance ngZone by extending the one provided by Zone.js (which we will call outerZone) and setting its specific rules ⬇️

2

Once this ngZone is created, Angular subscribes to its specific hooks (which represent the detection system in the metaphor above). For example, whenever a piece of code is being executed in this zone, it triggers a hook named onTurnStart which leads to the associated operations being ran. Whenever that piece of code is done executing, it triggers another hook named onTurnDone which leads to the associated operations being ran (turning on the room lights in the metaphor above).

This is an example of what the code looks like for the onTurnDone hook, more or less ⬇️

Capture d’écran 2023-03-10 à 10.11.20

Angular accesses its ngZone then subscribes to onTurnDone and once it has been fired it executes tick() (which triggers a change detection cycle).

What does it have to do with change detection?

Enough with the metaphors. As we have seen above, Angular is first gifted an outerZone by Zone.js and then forks it into an ngZone that captures the events that happen inside it and acts upon them. Let’s expand a little bit on the latter idea.

What happens under the hood is that Zone.js monkey-patches most asynchronous Browser APIs. Monkey-patching is a technique used in programming to dynamically modify the behavior of an existing method (when it is called) by replacing its implementation with a new one.

Below are a handful of well-known browser APIs ⬇️

  • addEventListener - used to attach an event handler function to an element (a button click for example)
  • XMLHttpRequest (also called XHR, we mentioned them earlier) - enables the exchange of data between a client and a server
  • Geolocation API - provides access to the device's geographic location information
  • Web Storage API (localStorage and sessionStorage) - allows you to store key-value pairs locally in the browser and retrieve them even after the browser is closed and reopened

For example, saying that addEventListener is monkey-patched means that whenever you will call this method, you will actually call another method that wraps it ⬇️

3

By default, every line of code you write in your application is executed inside ngZone, which means that it will be spied on and told upon. Depending on the information it receives, Angular will decide if a change detection cycle must be ran or not. In the case of a button click (do not forget that addEventListener has necessarily been called to listen on that click!), as we have seen above, as soon as Angular gets told that the click happened, it will trigger a change detection cycle to detect a change in the application’s state and update the views accordingly.

You might intuitively ask yourself what actually is the use of the outerZone if everything is ran inside ngZone. Fair question. The outerZone can be relied on for code blocks that you want to be executed out of Angular’s sight.

Suppose you want your application to display an animated flickering light bulb. One way you could implement that would be to set a timer that changes the background color of the object every, say, 100ms.

bulb

Capture d’écran 2023-03-10 à 10.15.03

With this implementation, every 100ms, Angular will have to trigger a change detection cycle on the whole application (you’ll have to trust me on that until the next section). That is an awful lot of change detection cycles for that poor little animation.

To make that better, you can inject the NgZone service into your component or service and use the runOutsideAngular method to execute a function outside the Angular zone.

Capture d’écran 2023-03-10 à 10.15.17

By wrapping the setInterval function inside runOutsideAngular, we're telling Angular not to trigger a change detection cycle after every increment.

Now that we’ve established the fundamentals, let’s see what a change detection cycle looks like and how the strategy we choose modifies its behavior.

ChangeDetectionStrategy.Default

Angular provides a component-level property called ChangeDetectionStrategy that you can set in the @Component decorator. This property takes two values : Default (you actually don’t need to explicitly set it like I did below, it’s the default value) and OnPush ⬇️

Capture d’écran 2023-03-10 à 10.11.42

The fact that the ChangeDetectionStrategy is defined at component-level means that each component of your application gets its own ChangeDetectorRef, which also means that you can precisely decide how change detection behaves and use that to improve your application’s performance. We will talk about that in the last section.

This section will be dedicated to getting a good idea on how the Default strategy works.

Let’s first remember that in Angular, an application is basically a tree of components with a root component at the top, and all its children as its leaves.

4

Let’s imagine that a user has clicked on a button on the component B_3_2. From the previous sections, we know that ngZone will catch it and eventually trigger the onTurnDone, which will in turn trigger a change detection cycle.

  • Angular will start from the root component, check if the component’s state has changed and update its view accordingly
  • It will then go down a level and do the same work for components A_1 and B_1
  • It will then go down a level and do the same work for components A_2_1, A_2_2, A_2_3, B_2_1 and B_2_2
  • Finally, it will go down a level and do the same work for components A_3_1, A_3_2, A_3_3, B_3_1 and B_3_2

default

Before going further, let’s pause and notice that change detection is ran from top to bottom. We owe it to the unidirectional data flow principle. According to the Angular documentation ⬇️

Angular's unidirectional data flow rule forbids updates to the view after it has been composed.

What it means essentially is that data moves from the parent to the child, but not in reverse. Any modifications made to the parent data will be reflected in the child data. However, if there are any alterations made to the child data, they will not automatically apply to the parent data. To update the parent data with changes made in the child data, you must explicitly send an event to the parent and instruct it to update the specific data that was modified.

That ensures that once a component has been updated, the component’s data will not be altered during the same change detection cycle.

We can see that with the default change detection strategy, a user event happening on the component B_3_2 had Angular run change detection on each and every one of the application’s components.

Imagine now that the action associated to this button click is specific to component B_3_2. As a developer, you then know that it has absolutely zero impact on component A_3_3 (for example). Still, anytime a user will click that button, Angular will go through the whole component tree to check if something has changed in any other component.

It might seem like an overreaction to you, the developer, but Angular is just honoring the contract it has with you. Remember, Angular transforms the application state into a user interface. If you don’t explicitly tell it that this button click only affects the state of B_3_2 and nothing else, Angular has to check for itself.

In the next section, we will see how we can leverage the OnPush strategy to tackle this issue, save Angular a good number of operations and eventually improve performance.

ChangeDetectionStrategy.OnPush

Following up on the previous example, this section will focus on how change detection runs when you provide Angular with a way to know that the component A_3_3 has not been affected by the button click on component B_3_2. In order to achieve that, we will rely on ✨immutability✨.

The main difference between mutable and immutable objects is that mutable objects can be modified in place and immutable objects can’t ⬇️

  • Mutable objects : their properties or values can be updated without creating a new object (ie. without changing their reference)

Capture d’écran 2023-03-10 à 10.12.05

person's reference has not changed in the process

  • Immutable objects : they require the creation of a new object (ie. with a new reference) whenever a change is made to their state

Capture d’écran 2023-03-10 à 10.12.10

person's reference has changed in the process

In JavaScript, by default, only primitives (examples: string, boolean, number) are immutable. You can enforce immutability on your application by using dedicated librairies such as Immutable.js.

What does it have to do with change detection ?

Let’s use the OnPush strategy on component A_3_3 ⬇️

Capture d’écran 2023-03-10 à 10.12.15

When we use this change detection strategy, we basically tell Angular that component A_3_3 only needs to be updated in the following cases ⬇️

1. At least one input reference has changed
2. The component (or one of its children) has triggered an event handler - a button click handler for example
3. An observable linked to the component’s (or one of its children’s) template via the async pipe has emitted a new value
4. Change detection has been “manually” triggered. There are a few ways we can force change detection on a component, and that could come in handy when working with Observables but since this post is an introduction to the change detection mechanism, I prefer not to dive into it here.

Ensuring object immutability in our component A_3_3 ensures that change detection will be ran on it when one of its inputs changes, and still allows for it to be skipped if not.

Let’s now apply this to all our components. We set them to ChangeDetectionStrategy.OnPush and go back to the previous test case : imagine that a user has clicked a button on the component B_3_2 ⬇️

onpush

  • As always, Angular starts with the root component. In our case, it checks rule n°2 so change detection runs on it
  • Then Angular goes down a level and checks components A_1 and B_1
    • Component A_1 doesn’t check any rule (remember, the actions following the button click have no impact on it) so it is skipped
    • Component B_1 checks rule n°2 so change detection runs on it
  • Then Angular goes down a level and does the same work for components A_2_1, A_2_2, A_2_3, B_2_1 and B_2_2
    • Components A_2_1, A_2_2 and A_2_3 are children to component A_1 which has been skipped. Unidirectional data flow (see above if you forgot what it implies) means that there is no need to check them ****therefore they are skipped
    • Component B_2_1 doesn’t check any rule so it is skipped
    • Component B_2_2 checks rule n°2 so change detection runs on it
  • Finally Angular goes down a level and does the same work for components A_3_1, A_3_2, A_3_3, B_3_1 and B_3_2
    • Component A_3_1, A_3_2, A_3_3 and B_3_1 are respectively children to components A_2_1, A_2_2, A_2_3 and B_2_1 which have been skipped. Unidirectional data flow means that there is no need to check them therefore they are skipped
    • Component B_3_2 checks rule n°1 and rule n°2 so change detection runs on it

To make sure that everything is clear, let’s shuffle things up by repeating the experiment and imagining that some components are left with ChangeDetectionStrategy.Default and some others are set to ChangeDetectionStrategy.OnPush (please refer to the next component tree for the ChangeDetectionStrategy distribution).

Let’s have a user click a button on the component B_3_2 ⬇️

shuffle

  • As always, Angular starts with the root component. In our case, it checks rule n°2 so change detection runs on it
  • Then Angular goes down a level and checks components A_1 and B_1
    • Component A_1 is set to ChangeDetectionStrategy.Default so change detection runs on it
    • Component B_1 is set to ChangeDetectionStrategy.OnPush and checks rule n°2 so change detection runs on it
  • Then Angular goes down a level and does the same work for components A_2_1, A_2_2, A_2_3, B_2_1 and B_2_2
    • Component A_2_1 and A_2_3 are set to ChangeDetectionStrategy.OnPush and don’t check any rule so they are skipped
    • Component A_2_2 and B_2_1 are set to ChangeDetectionStrategy.Default so change detection runs on them
    • Component B_2_2 is set to ChangeDetectionStrategy.OnPush and checks rule n°2 so change detection runs on it
  • Finally Angular goes down a level and does the same work for components A_3_1, A_3_2, A_3_3, B_3_1 and B_3_2
    • Component A_3_1 and A_3_3 (regardless of their ChangeDetectionStrategy) are respectively children to components A_2_1 and A_2_3 which have been skipped. Unidirectional data flow means that there is no need to check them ****therefore they are skipped
    • Component A_3_2 is set to ChangeDetectionStrategy.OnPush and checks rule n°2 so change detection runs on it
    • Component B_3_1 is set to ChangeDetectionStrategy.OnPush and doesn’t check any rule so it is skipped
    • Component B_3_2 checks rule n°1 and rule n°2 so change detection runs on it

In theory, if you have a perfect knowledge about the mechanism, you could apply ChangeDetectionStrategy.OnPush to each and everyone of your components. But that would be hard work without sufficient compensation. Here are some non-exhaustive recommandations about when and when not to use OnPush ⬇️

  • You have a component that solely depends on immutable input parameters. In this case, you can use OnPush to optimize performance by only checking for changes when a new input reference is received ✅
  • You have a component which has heavy rendering (such as components that display charts, graphs etc.). In this case, you can use OnPush to save computation power by avoiding to render the component when it is not necessary ✅
  • You have a component that is getting passed inherently mutable objects as FormControl or FormGroup. In this case, I would not recommend using OnPush as it would require “manual” change detection triggering (see rule n°4) ❌
  • You have a ”top-level” component that manages asynchronous tasks such as fetching data. In this case, I would not not recommend using OnPush as it will require more effort and attention, most probably without presenting equivalent performance gains ❌

If you made it this far, kudos 👍 and thank you for reading. You now won’t fall into the change detection trap if you stumble upon a component that relies on OnPush strategy.

Furthermore, by applying the OnPush strategy, we freed Angular from the need of being conservative and checking every single one of the tree nodes whenever a user event occurs, and made it skip change detection on entire subtrees instead, which eventually leads to making our application faster.

We welcome you to explore our Tech Blog for more engaging posts if you found this one enjoyable. Additionally, if you are eager to improve your tech skills, we are pleased to inform you that we are accepting applications for several job positions. You can find them 👉 here 👈.