Exploring Angular's Change Detection: In-Depth Analysis
Jarosław Żołnowski
Posted on July 7, 2024
Understanding Change Detection
Change detection in Angular is a key process that keeps the app's state and user interface in sync. It's how Angular makes sure our UI updates when the underlying data changes. Without it, any changes in your app wouldn't show up in the UI automatically, making the app inconsistent and unreliable. Change detection is important because it keeps the UI accurate, ensuring every user action, data fetch, or event is shown correctly, making the app responsive and user-friendly.
How Change Detection Works in Angular
The change detection process in Angular involves two main stages:
Marking the Component as Dirty: This initial stage occurs when an event that can alter the state of a component happens. For instance, a user clicks a button, which triggers Angular to mark the affected component as
dirty
. This marking indicates that the component requires a check-up for potential changes.Refreshing the View: This is where
zone.js
comes into play. zone.js is a library that helps Angular track asynchronous operations like XHR requests, DOM events, and timers (e.g.,setInterval
,setTimeout
). When an asynchronous event occurs, Angular captures it through the onMicrotaskEmpty Observable:
this._onMicrotaskEmptySubscription = this.zone.onMicrotaskEmpty.subscribe({
next: () => {
if (this.changeDetectionScheduler.runningTick) {
return;
}
this.zone.run(() => {
this.applicationRef.tick();
});
},
});
And traverse the view tree to detect and propagate any changes:
for (let {_lView, notifyErrorHandler} of this._views) {
detectChangesInViewIfRequired(
_lView,
notifyErrorHandler,
isFirstPass,
this.zonelessEnabled,
);
}
In Angular, a view
refers to an instance of ViewRef. Think of ViewRef
as a box that contains a bunch of important information about the component, such as the current state of inputs, the template, directives being used, and binding statuses. Each component has its own ViewRef
box, making it easier for Angular to manage and update components during change detection.
The detectChangesInViewIfRequired
method triggers the detectChangesInView, which defines the criteria for checking components, inspecting various flags associated with the view and its mode, to determine if any updates are necessary.
If the view needs refreshing, the refreshView method is called. This method executes the template function (which is a component template compiled into a regular JavaScript function) with the render flags and context to generate the component view and starts a chain reaction by triggering event detection for the view's child components through the detectChangesInChildComponents method.
So to perform change detection, Angular traverses the views' tree and executes template functions on each component.
Default Change Detection
In the beginning, most of us used Default change detection - it's like being in the middle of a bustling city – there's noise everywhere, distractions pulling your attention in every direction. This is how it was with Angular's default change detection. It keeps an eye on everything, even when nothing's happening. And sure, it works, but it's a bit overkill, causing performance hiccups, especially as your app grows.
This strategy is the default change detection mechanism, and it’s applied automatically unless we explicitly override it with an OnPush strategy. It relies on the CheckAlways flag which means that Angular will run change detection for all components whenever any event occurs.
let shouldRefreshView: boolean = !!(
mode === ChangeDetectionMode.Global && flags & LViewFlags.CheckAlways
);
So if this flag is set, then it calls the mentioned refreshView
method (shouldRefreshView
flag is set to true
),
if (shouldRefreshView) {
refreshView(tView, lView, tView.template, lView[CONTEXT]);
}
which refreshes not just the current view but also all its child views, if there are any. This ensures everything stays consistent, but it can be inefficient for larger apps because it might do more checks than needed.
So, it's kind of like a domino effect, making sure the entire component tree gets updated, whatever happens.
We have the ability to control this one way or another. We can explicitly detach and reattach the view from the change detection tree using detach() and reattach() methods respectively.
@Component({
selector: 'detached', template: `Detached Component`
})
export class DetachedComponent {
constructor(private cdr: ChangeDetectorRef) {
cdr.detach();
}
}
If we detach the view from the change detection, Angular will skip it, regardles of any changes chappend.
OnPush Change Detection Strategy
For better performance, Angular offers the OnPush
change detection strategy. This strategy tells Angular to skip change detection for the component unless one of its inputs changes, we mark component to check using markForCheck
method or an event occurs, for example a XHR request handled by an async
pipe.
By focusing on what's changed, rather than checking everything all the time, OnPush
makes apps faster and more efficient, reduces unnecessary updates, improves performance, and prevents potential disasters.
Using OnPush
helps optimize performance by reducing the number of times change detection runs, especially in large and complex applications.
So, how does it work? I mentioned that OnPush
change detection gets triggered when we do things like setting a new Input value in a child component.
Now, let's get back to the Angular source code, and verify the part that runs when we set a new Input value.
So, when the setInput
method is called, it confirms that the Input value actually has been changed,
if (
this.previousInputValues.has(name) &&
Object.is(this.previousInputValues.get(name), value)
) {
return;
}
marking the component view as Dirty
in the markViewDirty method, which basically tells Angular,
"Hey, this child component's view needs updating, so take a look next time you check for changes."
The same idea applies when, for example, we are using an Async pipe.
It subscribes to observables, which updates the latest value by calling the markForCheck function,
private _updateLatestValue(async: any, value: Object): void {
if (async === this._obj) {
this._latestValue = value;
if (this.markForCheckOnValueUpdate) {
this._ref?.markForCheck();
}
}
}
which, in turn, triggers the markViewDirty method.
markForCheck
is also used in a few other scenarios. For example if we want to initiate the change detection manually, we handle the DOM event, or attaching, detaching a view. And in all these scenarios at some point we call the markViewDirty method.
while (lView) {
lView[FLAGS] |= dirtyBitsToUse;
const parent = getLViewParent(lView);
if (isRootView(lView) && !parent) {
return lView;
}
lView = parent!;
}
This method is like a domino effect – it starts with the specified view and moves up the family tree, by checking if there’s a parent,
if (isRootView(lView) && !parent)
marking each view along the path as Dirty, meaning they all need checking.
const dirtyBitsToUse = isRefreshingViews() ? LViewFlags.Dirty : LViewFlags.RefreshView | LViewFlags.Dirty;
while (lView) {
lView[FLAGS] |= dirtyBitsToUse;
…
}
And when it's time to do the actual checking, Angular looks at those views marked as dirty.
shouldRefreshView ||= !!(
flags & LViewFlags.Dirty &&
mode === ChangeDetectionMode.Global &&
!isInCheckNoChangesPass
);
If a component's view is marked as Dirty
, it means something in that component has changed, and it needs to be re-rendered. And we do that by calling the refreshView method.
When we click on the component, it triggers a change detection in the component itself and all its parent components, which is exactly what we should expect, because we marked all its ancestors as Dirty
in the markViewDirty
method.
Signals Era
Since version 16, Angular has been introducing Signals - a wrapper around a value that can notify interested consumers when that value changes. It can contain any value, from simple primitives to complex data structures, and we can read the value through a getter function, which allows Angular to track where the signal is used.
Now, the cool part is, Angular allows us to utilize this Signal power and combine it with the OnPush
strategy to mark certain components for updates.
This little trick eliminates the need for unnecessary checks on components, whether they're parent or child elements.
Let's see what makes the signals work the way they do. Whenever we tweak a signal within a component's template, using methods like set()
or update()
, it's like setting off a little chain reaction.
First, Angular calls signalSetFn, which will send out a notification to the live consumer waiting in the view,
function signalValueChanged<T>(node: SignalNode<T>): void {
…
producerNotifyConsumers(node);
…
}
making it go, "Hey, I'm dirty!"
for (const consumer of node.liveConsumerNode) {
if (!consumer.dirty) {
consumerMarkDirty(consumer);
}
}
Then, it marks all its ancestors, right up to the root, with a flag called HasChildViewsToRefresh, indicating
"Hey, I've got some child views here that need to be refreshed."
while (parent !== null) {
if (parent[FLAGS] & LViewFlags.HasChildViewsToRefresh) {
break;
}
parent[FLAGS] |= LViewFlags.HasChildViewsToRefresh;
if (!viewAttachedToChangeDetector(parent)) {
break;
}
parent = getLViewParent(parent);
}
If we take a look closer, we'll see that this method is pretty similar to the markViewDirty
method. The difference is, instead of marking all parent views with the Dirty
flag, we mark them with the HasChildViewToRefresh
flag.
Now, as you already know, the change detection mechanism consists of two parts and the subsequent part traverses the tree of views. So let's back into our detectChangesInView
method that evaluates whether a view requires updating.
Now, here's the clever part - when a view is marked with this HasChildViewsToRefresh flag, there's no need to re-render it. Angular skips right ahead to checking out the child component view if there's any.
else if (flags & LViewFlags.HasChildViewsToRefresh) {
detectChangesInEmbeddedViews(lView, ChangeDetectionMode.Targeted);
const components = tView.components;
if (components !== null) {
detectChangesInChildComponents(lView, components, ChangeDetectionMode.Targeted);
}
}
This smart shortcut helps Angular avoid wasting time on unnecessary stuff, ensuring smooth and efficient operation!
When we use e.g. the setInterval
function to update the signal value in Component D
, only the components directly touched by the signal change actually get refreshed.
OnPush
within Signals is like a sniper shot for detecting changes. It allows us to specify which components should be re-rendered when certain signals change. This means that instead of refreshing the entire component tree or a single branch when using OnPush
, we only re-render the components directly affected by the signal change.
Wrap up
Default change detection relies on the CheckAlways
flag, triggering updates for the entire view tree, whatever happens. This keeps everything consistent but can be overkill, causing unnecessary performance hits as the app grows.
OnPush
change detection is a powerful optimization technique in Angular. It ensures that components are only re-rendered when their inputs change, when events occur within the component or when we manually trigger change detection using the markForCheck
method. It's important to note that when we use OnPush
, the change detection refreshes not only the component itself but also all its parent components in the component tree.
OnPush
within Signals is a more targeted approach to change detection. It allows us to specify which components should be re-rendered when certain signals change. This means that instead of refreshing the entire component tree or a single branch when using OnPush
, we only re-render the components directly affected by the signal change.
Posted on July 7, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.