How to use Angular Signals and OnPush change detection strategy to improve performance with local change detection
For quite some time now, the Angular community has been witnessing a lot of improvements and new features being added to their beloved framework. A few weeks ago, Angular v17 was released introducing a ton of high-quality improvements and features, with signals APIs (except effect()) making their way out of developer preview. With performance always being the discussion topic in the community regarding change detection, stable signal APIs seem to make the framework more powerful and smarter in change detection 🤯.
You can read more about Angular v17 in the official blog post.
Some time ago, the Angular team started working on finding a way to introduce locality
awareness in the framework (when detecting changes) in the sense of figuring out what impact a change of state has on the application thus not depending on Zone.js anymore.
For this purpose, in Angular v17, conforming to the current Zonejs-based, top-down change detection approach, signals have been fine-tuned, providing a starting point toward locality awareness, thus introducing some sort of what the Angular community calls Local Change Detection 🚀.
This is a great ability that has been added to the framework through signals and is expected to provide performance gains, but is this what the community has been thriving for? is this going to reduce the amount of checks needed during a change detection cycle? and is it somehow related to change detection strategies?
In this article, we are going to elaborate and see how that locality
awareness is achieved, conditions for it to be used effectively and properly, and show some working examples. Let's dive in 🐱🏍.
Change Detection Modes
Before Angular v17, whenever an event happens that Zone.js is patching, which may lead to state changes, Zone.js picks up this event and informs Angular that some state changed (not specifically where) and that it should run change detection. Since Angular does not know where a change comes from or where the change happened, it starts traversing the component tree and dirty-checking the all components. This approach to detect changes is known as the Global mode.
With signals🚦, this approach is going to be fine-tuned in the sense that no need to dirty-check all the components anymore. Signals can track where they are being consumed. For signals bound on the component template, the template is a consumer, that every time the signal value changes, needs to pull out that new value (hence live consumer). Thus when signal value changes, it is enough to mark its consumers as dirty, but not need the same for ancestor components (as it was pre-v17). For this purpose, a new improvement shipped in the latest version makes signals smart enough to mark dirty only that specific component it is consumed on (live consumer) and marks ancestor components for traversal (by adding the HasChildViewsToRefresh
flag).
To achieve this, a new function markAncestorsForTraversal
is introduced replacing markViewDirty
(which is used to mark dirty also all ancestor components). Let's look at the underlying code:
export function consumerMarkDirty(node: ReactiveNode): void {
node.dirty = true;
producerNotifyConsumers(node);
node.consumerMarkedDirty?.(node);
}
consumerMarkedDirty: (node: ReactiveLViewConsumer) => {
markAncestorsForTraversal(node.lView!);
},
export function markAncestorsForTraversal(lView: LView) {
let parent = lView[PARENT];
while (parent !== null) {
// We stop adding markers to the ancestors once we reach one that already has the marker. This
// is to avoid needlessly traversing all the way to the root when the marker already exists.
if ((isLContainer(parent) && (parent[FLAGS] & LContainerFlags.HasChildViewsToRefresh) ||
(isLView(parent) && parent[FLAGS] & LViewFlags.HasChildViewsToRefresh))) {
break;
}
if (isLContainer(parent)) {
parent[FLAGS] |= LContainerFlags.HasChildViewsToRefresh;
} else {
parent[FLAGS] |= LViewFlags.HasChildViewsToRefresh;
if (!viewAttachedToChangeDetector(parent)) {
break;
}
}
parent = parent[PARENT];
}
}
During change detection (triggered by Zone.js), components marked for traversal, when visited, let Angular understand that they don't need to be checked for changes but that they have dirty children. This way ensures they are accessible during the change detection process even when they are not dirty but skipped on the way to the dirty children which are refreshed. As a result, the new ability of signals to localize where in the component tree the change has occurred provides the "Local" change detection we talked about earlier. The improved approach enforced by signals is known as the Targeted mode of detecting changes. In this mode, Angular still initiates a top-bottom checking process (recall triggered by Zone.js), but now it traverses through components marked for traversal, and targets to refresh only dirty consumers.
This approach certainly seems to bring performance gains in overall application but do we have it out of the box or is it an opt-in feature?
Change Detection Strategies
In general, the best way to address performance concerns is by doing less work, which means, running less code, and in Angular, this means reducing change detection cycles and the number of components being checked for changes during a cycle. To achieve this, Angular needs a way to know which components must or not be checked for changes.
Since Angular is not able to know exactly which component has changed, and change detection is global, the top-bottom process, assumes that all components in the tree are dirty, and need to be checked for changes on every cycle. This means that components, dirty or not, will be checked for changes. This behavior and conformance of components regarding the change detection process is referred to as a change detection strategy. Since this is the default component behavior, it is known as the Default change detection strategy.
To improve this behavior, and make Angular do less work, the Angular team introduced a new strategy that reduces the number of components to be checked for changes. This new strategy is known as the OnPush change detection strategy, which allows skipping subtrees of components that are not dirty.
There are 3 criteria when the OnPush component is marked dirty, and you can find more about them in this article.
Now, based on this information, we can allude that the OnPush change detection strategy seems to let us benefit from v17's Local change detection. Let's find out if it stands 💪.
Hybrid Change Detection
For demo purposes, below is a small reproduction of the app supporting our case:
@Component({
...
selector: 'app-child-y',
templateUrl: `
<div class="container">
<h3>Child Y<br /> value: {{ count() }} runs: {{getChecked()}}</h3>
<app-grandchild-y />
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
...
})
export class ChildYComponent {...}
@Component({
...
selector: 'app-child-x',
templateUrl: `
<div class="container">
<h3>Child X <br /> value: {{ count() }} runs: {{getChecked()}}</h3>
<app-grandchild-x />
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
...
})
export class ChildXComponent {...}
@Component({
...
selector: 'app-parent',
templateUrl: `
<div class="container">
<h2>Parent <br /> value: {{count()}} runs: {{getChecked()}}</h2>
<div class="children">
<app-child-x />
<app-child-y />
</div>
</div>
`,
...
})
export class ParentComponent {...}
The app visualizes a component tree, with the Parent component having Default change detection, and its two child components, ChildX and ChildY components, each with OnPush change detection, and one child component, GrandChildX component and GrandChildY component respectively, as below:
@Component({
...
selector: 'app-grandchild-x',
templateUrl: `
<div class="container">
<h4>(GrandChild X <br /> value: {{ count() }} runs: {{getChecked()}}</h4>
<button (click)="updateValue()">Increment Count</button>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
...
})
export class GrandchildXComponent {
...
updateValue() {
this.count.update((v) => v + 1);
}
}
@Component({
...
selector: 'app-grandchild-y',
templateUrl: `
<div class="container" appColor>
<h4>(GrandChild Y <br /> value: {{ count() }} runs: {{getChecked()}}</h4>
<button #incCount>Increment Count</button>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
...
})
export class GrandchildYComponent implements AfterViewInit {
...
@ViewChild('incCount') incButton!: ElementRef<HTMLButtonElement>;
ngZone = inject(NgZone);
injector = inject(Injector);
app = inject(ApplicationRef);
ngAfterViewInit(): void {
runInInjectionContext(this.injector, () => {
this.ngZone.runOutsideAngular(() => {
fromEvent(this.incButton.nativeElement, 'click')
.pipe(throttleTime(1000), takeUntilDestroyed())
.subscribe(() => {
this.count.update((v) => v + 1);
this.app.tick();
});
});
});
}
}
The only difference between grandchild components is the way the click event handler is handled. In the following section, we will understand the reason behind this decision.
Now that on both sides of the component tree, all components are using the OnPush strategy, let's check what happens when incrementing the count at the GrandChildX component:
What we are witnessing here is no difference as if it was a Global strategy in use. Even though components use the OnPush strategy they are still being checked for changes. Why is this happening?
Well, Angular internally wraps event listeners in a function that marks the component and its ancestors for check when an event happens. In addition, Zone.js monkey-patches the event and notifies Angular when it's fired so that Angular can start change detection. So in our case, when we click the button, it marks current and ancestor components as dirty. And since change detection starts in Global mode, the whole tree is refreshed. This is how change detection used to work before v17 and was left intact for backward compatibility purposes.
Based on this, we can conclude that to get Local CD benefits, we have to: update the signal in a way that doesn't mark for checking all the ancestor components.
Hmm🤔… okay. We took care of the increment button at the GrandChildY component to respect this requirement (credits Thomas Laforge), so let's see what we get now 👇:
And yay 😊. Now we get the local change detection in our application. To be more clear, when the button is clicked, the signal value gets updated, which then marks the current component(consumer) as dirty and the ancestor components for traversal. After that, Zone.js gets triggered (remember monkey-patching) which notifies Angular, then Angular starts change detection in Global mode and refreshes the Parent component because it uses the Default change detection strategy, switches to the Targeted mode when visiting components marked for traversal (with the HasChildViewsToRefresh
flag), and uses the OnPush change detection strategy, ChildY component in this case, traversing them but not refreshing, then finally coming to the dirty GrandChildY component (consumer), switching back to the Global mode of change detection, and refreshing the view. This back-and-forth switching that Angular does between change detection modes, Global and Targeted, is known as Hybrid change detection 🐱🏍.
If you want to see how this is managed internally, check the source code at Angular repo.
Now, with all of those things mentioned, someone can say that there seem to be not too many places where we would get the benefits of local change detection. Indeed, we won't get the benefit all over the place, but also the click handling above is not the usual thing we do a lot. But there are other use cases where we can get this benefit (credits Enea Jahollari), see below:
Here you can see a more usual case, where you have some shared state in service, a typical case being state management libraries (NgRx, Akita, …), and that piece of state is being used in many components throughout the component tree. Also, the case of having some state on the parent component that can be consumed from child components. When this state (remember a signal value ⚠) changes, only the components that consume (consumers) the state are marked dirty, and with the OnPush CD strategy set on, you will get the benefits of local change detection.
This is a typical use case to mention and, if you have in mind other use cases where we can benefit from local change detection, feel free to share them with me and the community.
Conclusion
Improving change detection has always been one of the priorities of the Angular team and community. This kind of locality that we have now in v17 is not the complete one but it represents a good start and initial step toward what is expected to be delivered on future versions. Even though not provided out of the box, developers can leverage local change detection benefits in their apps with a few adjustments: OnPush change detection strategy and Signals. To know more what's the Angular team's plan regarding reactivity, check the released roadmap 🔥.
Here is a visualization of all that was presented in the article:
You can find and play with the final code here: https://stackblitz.com/edit/local-cd-angular-17?file=src%2Fmain.ts
Additional Resources
If you want to read more about local change detection in v17 make sure you check these resources.
Special thanks to @kreuzerk, @eneajaho, @tomastrajan, and Matthieu Riegler for review.
Thanks for reading!
I hope you enjoyed it 🙌. If you liked the article please feel free to share it with your friends and colleagues.
For any questions or suggestions, feel free to comment below 👇.
If this article is interesting and useful to you, and you don't want to miss future articles, follow me at @lilbeqiri, dev.to, or Medium. 📖
Top comments (1)
Good article. I will study the stackblitz to understand the concept of local change detection