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) ⬇️
In an attempt to preserve your scalp, I will introduce you to Angular’s Change Detection mechanism and the ABCs of improving its performance.
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 :
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.
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 :
setTimeout()
, setInterval()
, etc.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.
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 ⬇️
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 ⬇️
Angular accesses its ngZone then subscribes to onTurnDone and once it has been fired it executes tick() (which triggers a change detection cycle).
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 serverlocalStorage
and sessionStorage
) - allows you to store key-value pairs locally in the browser and retrieve them even after the browser is closed and reopenedFor example, saying that addEventListener
is monkey-patched means that whenever you will call this method, you will actually call another method that wraps it ⬇️
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.
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.
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.
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 ⬇️
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.
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.
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.
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 ⬇️
person
's reference has not changed in the process
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 ⬇️
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 changedasync
pipe has emitted a new valueEnsuring 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 ⬇️
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 ⬇️
ChangeDetectionStrategy.Default
so change detection runs on itChangeDetectionStrategy.OnPush
and checks rule n°2 so change detection runs on itChangeDetectionStrategy.OnPush
and don’t check any rule so they are skippedChangeDetectionStrategy.Default
so change detection runs on themChangeDetectionStrategy.OnPush
and checks rule n°2 so change detection runs on itChangeDetectionStrategy
) 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 skippedChangeDetectionStrategy.OnPush
and checks rule n°2 so change detection runs on itChangeDetectionStrategy.OnPush
and doesn’t check any rule so it is skippedIn 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
⬇️
OnPush
to optimize performance by only checking for changes when a new input reference is received ✅OnPush
to save computation power by avoiding to render the component when it is not necessary ✅FormControl
or FormGroup
. In this case, I would not recommend using OnPush
as it would require “manual” change detection triggering (see rule n°4) ❌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 👈.