Understanding how Angular ChangeDetectionStrategy.OnPush works for @Input, Service and ng-content
Observe and analyze by debugging step by step through ChangeDetectionRef internal methods
Introduction:
Whenever we talk about performance in Angular based application, Change Detection Strategy plays an important role. In this article, we will observe and analyze how change detection works by going through a couple of cases where each case has component(s) laid out in a particular format and uses either Default or OnPush Strategy.
Overview:
In the Angular framework, the view is automatically updated anytime the underlying component’s data is changed. This is achieved by triggering angular change detection where angular provides a change detector for each component. By default the change detection is automatic and the detection cycle runs multiple times within a set period of time and trigger conditions are many.
One of the heaviest operations through the change detection cycle is re-rendering the view (paint operation). When you have n
number of components and if the change detection runs x
number of times where x
is dependent on various trigger conditions such as mouseover, click, setInterval, timeout or underlying HTTP call, etc.,
As the n
increases then n*x
the value increases exponentially which ultimately is very bad for the overall performance of the application. Mathematically it may not be an accurate representation but it gives you an overview of what kind of impact that it will make on the overall performance of the application. The framework provides two options i.e., ChangeDetectionStrategy.Default
(CheckAlways) and ChangeDetectionStrategy.OnPush
( CheckOnce).
When a component doesn’t explicitly define the ChangeDetectionStrategy then Default is selected and as the name suggests the change detection cycle runs every time and re-renders the view irrespective of whether underlying data is changed or not. However, with OnPush the automatic change detection is disabled but runs only when underlying data is changed or when invoked explicitly by the component via methods exposed by ChangeDetectionRef
abstract class.
Now that we had a quick overview, let’s go through a couple of use cases and observe how change detection works:
Use Case 1: OnPush vs Default when passing data from parent to child components via @Input()
As shown in the above code snippets and picture, created two components:
1. Both DefaultBoxComponent
& OnPushBoxComponent
and the associated template has almost the same code except in the case for changeDetectionStrategy
they are set as Default
and OnPush
, respectively. (see line 11,10)
2. AppComponent
passes boxes
array data to these components and the corresponding variable is declared with @Input()
decorator (see line 15,14)
3. AppComponent
has two buttons, Add
and No Action
where on click of these buttons the former will push an item into the boxes
array and later does nothing, respectively.
Let’s run the app and see:
DefaultBoxComponent alone is updated.
Observation:
When we click on the Add button, a new item is added to the boxes array and at the same time view is also updated accordingly for
DefaultBoxComponent
but not forOnPushBoxComponent
although the same boxes data object is passed by the parentAppComponent
?
Reason:
If you observe both these components, the only variation is the ChangeDetectionStrategy
where for DefaultBoxComponent
it was set at Default( a.k.a CheckAlways
) while for OnPushBoxComponent
it was OnPush
(a.k.a CheckOnce
).
If we look at angular documentation or comments for each enum as in the angular code below, it says when the strategy value is 1
(Default
), the change detection is automatic but when the value is 0
(OnPush
) then automatic detection is deactivated but can be invoked explicitly.
Unlike in Default
mode, the change detection in OnPush
mode doesn’t automatically update all of its child components change unless otherwise marked for dirty or change detection triggered explicitly.
Invoking Change Detection Explicitly:
ChangeDectectorRef
class allows us to mark a specific view dirty so that the view can be re-rendered in the next change-detection cycle.
After reading through the angular documentation, I came across how we can use DoCheck
angular life cycle hook to implement custom change detection.
Step 1: Update OnPushBoxComponent
to implement DoCheck
life cycle hook.
Step 2: Implement ngDoCheck()
method. Here, I just added console.log to see when and how this lifecycle hook was getting called.
Step 3: Repeated the above two steps for DefaultBoxComponent
and AppComponent
.
Observation:
On Click of Add button the boxes array was updated with new item.DefaultBoxComponent
sections view updated and we can see the new items added but theOnPushBoxComponent
view remains unchanged. However, on the right side console window, we can see thatngDoCheck
hook is called for the root component and all its children which is evident via the log entry.
Analysis:
The change-detection trigger originates from the root component i.e., AppComponent, and propagates to all child components. Every time component’s change detection triggers the DoCheck lifecycle hook is called and based on the strategy declared view is re-rendered or not.
On Click of Add
Button, the boxes
array is updated with a new item. Since AppComponent
set with Default
strategy, change detection triggers, and as designed its associated ngDoCheck
hook is called, and then change detection propagates to the rest of the child components. Since DefaultComponent
is set with Default a.k.a CheckAlways
strategy, the view is re-rendered automatically with the latest information. However, since OnPushCompoent
has OnPush a.k.a CheckOnce
Strategy, only life cycle hook is called but the view is not re-rendered and it is the responsibility of the component to decide whether it should be marked as dirty
for the component view to be re-rendered.
We can mark a view as dirty using the ChangeDetectionRef
abstract class methods. It exposes the following few methods:
1. markForCheck
: It can be used to mark a component dirty i.e., in need of re-rendering. The view is updated only when the next change detection runs.
2. detach
: To detach from change detection even if they are marked dirty until they are attached back
3. detectChanges
: Runs the change detection for the corresponding component and all its children.
4. checkNoChanges
: Can be used to check if there any changes associated with the component and throw an exception if any changes were identified
5. reattach
: Attach the view back to the change detector tree.
Updating the OnPushBoxComponent code to trigger custom change detection as below:
markForCheck
Note: In OnPush mode, instead of doing a custom change detection we can simply change the new instance of boxes array before adding a new item. Since the object reference has changed the @Input()
set method gets called which inherently re-renders the view as the underlying data got changed. But, since I wanted to explain how to trigger change detection explicitly, used the above approach as shown code snippet.
Let's run the app and see:
OnPushComponent
section is now getting updated with data passed by the AppComponent after implementing custom change detection via ChangeDetectionRef.markForCheck()Step through the code changeDetection.markForCheck() implementation:
Observation:
When ever a component is marked for check i.e., dirty, not only the current component but also all of its parent components will be marked as dirty. Here only the components are only marked dirty but the view is rendered only in next change detection cycle which may be immediate or may be at later time that may ultimately depend on the user’s action.
Analysis:
As seen in the above video, the markViewDirty,
which is a function that markForCheck
calls internally have the following implementation:
- Start a while loop and mark the current view as dirty until the
lView
is not null. Here thelView
is the state of the current view that is being processed. It contains an array of objects that may comprise of text, element container, or any local variables defined within the component. - For the current lView, get the parent
lView
and mark it asdirty
(see line 13594 wherelView[FLAGS]|=64
a Bitwise OR operation performed to set the flags to attribute to mark it as dirty). If the currentlView
is the root view then exit the loop. Most of the time the root view will be the parent of theapp-root
(AppComponent
).
Step through the changeDetectionRef.detectChanges() implementation:
markForCheck() from changeDetectorRef abstract class
Observation:
Using changeDetectionRef.detectChanges() actually triggers change detection which essentially re-renders the associated view irrespective of whether component is marked dirty or not.
Analysis:
detectChangesInternal()
which the function that ChangeDetectorRef.detectChanges()
uses actually refreshes view via refreshView
and all its children with the latest changes and re-renders the view via executeTemplate
irrespective of view was marked for dirty or not.
Case 2: Nested Components with the same data shared across components via a service class with changeDetectionStrategy.OnPush.
From the above picture, we can see that the AppComponent
has reference to BoxOneComponent
which has reference to BoxTwoComponent
which in turn has reference to BoxThreeComponent
.
Let’s run the app:
Observations:
WhenBox One
button is clicked, counter in theboxService
class is incremented but only theBoxOneComponent
view is updated. However, whenBox Two
button is clicked both BoxOneComponent and BoxTwoComponent view are updated to reflect latest count. Similarly, whenBox Three
button is clicked all three component views are updated to reflect the current counter value.
Analysis:
Unlike in Default
the change detection in OnPush
Mode doesn’t automatically update all of its change unless otherwise marked for dirty or change detection triggered explicitly.
After stepping through the code, I observed that on click of the button, markViewDirty
was getting called internally which by design marks all parent components as dirty (i.e., require view re-render).
Hence by design, when BoxThreeComponent’s Box Three
button was clicked all its ancestors i.e., BoxTwoComponent
and BoxOneComponent
were also marked as dirty
hence the view got reflected with an updated counter value from the BoxService
class.
Likewise, when BoxTwoComponent’s
Box Two
only BoxTwoComponent
and BoxOneComponent
were updated but not BoxThreeComponent
as it was a child to BoxTwoComponent
and not a parent(ancestor).
Case 3: Projected Components via ng-content with Change detectionstrategy.OnPush and the same data shared across all components via a service class.
In this case, the components are mentioned within the app.component.html as a nested element instead of referring to the components inside the respective components HTML template. Using the ng-content
, the components are projected dynamically to render a view similar to case 2.
Let's see how change detection works?
Observation:
On click of theProjected Box Three
button, counter in box service is updated and only box three projected component view got updated with latest value. Likewise, similar pattern was observed forProjected Box Two
andProjected Box One
respectively
Analysis:
After debugging through the markViewDirty
, we can see that even though it looks like projected-box-one
is parent of projected-box-two
which in turn is the parent for projected-box-three
but due to content projection via ng-content, all the above three components are actually the direct child of app-root
component. Hence when markViewDirty
is triggered only mat-button (button)->projected-box-three->app-root
are marked dirty when project-box-three
button was clicked due to which only projected-box-three
the view alone is re-rendered to display updated value.
Summary:
- We walked through a couple of use cases observed how change detection behaves.
- We observed that in default mode whenever the change detection cycle runs, the current component, and all of its child components view is refreshed.
- With the OnPush strategy, when a component is
markedForCheck
, then along with the current component all of its ancestor components are marked for dirty and all its view gets re-rendered when the next change detection cycle runs - When the components are projected via ng-content, even though they seem to look like they are nested components whatever observations that were made in case 2 doesn’t apply here as each projected component are a direct child of app-root component and hence on click of the button only associated component got re-rendered unlike in case 2.
Thank you for reading through the article, I hope this article provides a little better understanding of how change detection works in Angular. If you liked the article, please do clap/comment and share it with your friends/colleagues. If there any changes or corrections needed, please do comment, and will try to address as much as possible.
Resources:
- https://angular.io/api/core/ChangeDetectorRef#changedetectorref
- The pictorial illustrations were created using subscribed Microsoft PowerPoint