Local change detection and Angular Signals in templates in details

Vlad Sharikov
AngularWave

--

Hey there 👋! Let’s continue to talk about the Angular Signals system and how it works under the hood 🔍. Here is the table of contents for more straightforward navigation between articles:

Today, let’s talk about an amazing feature that became possible with Angular Signals — local change detection. I am eager to tell you all of that. It is a fascinating topic and I am pretty excited about it. A few days ago, I tweeted about it. These tweets contain a rough picture of what is happening with Angular Signals. I can’t wait to share details!

There is an existing way of working regarding change detection, which is pretty fast. With signals, Angular can check only the needed components, allowing it to do less work to achieve the same result.

Angular change detection is fast. With Angular Signals it is lightning fast 🚀🚀🚀. It is because now it can be very targeted while performing change detection.

As previously mentioned, Angular Signals shares much with other reactivity systems. The general name of the approach used in those tools is fine-grained reactivity. Using such a tool allows Angular to do only essential things. Let’s check how this happens in detail in Angular. I will structure this post in the following way:

  • explain the example which I use to discuss the topic
  • briefly: how does change detection work now?
  • what happens when you use a signal in the component’s template and how does Angular track it?
  • how exactly does Angular achieve local change detection?

TL;DR; or key takeaways

  • âś… With Signals, Angular can check fewer views and perform change detection much faster
  • âś… Now, every view (very roughly every component) has a corresponding signal
  • âś… This signal is like a computed signal or effect which can track signals used in components and invoke some logic on their change
  • âś… When any signal changes, the view ancestors are marked with a special flag, which helps Angular in a change detection process
  • âś… When any signal changes, the view’s corresponding signal is marked, which gives Angular the info that it must be checked for changes
  • âś… There is a reactivity graph under the hood. It allows signals to communicate with each other. That mechanism allows Angular to achieve Local change deteciton.

The example

I am going to discuss it in the following example. You can check it on Stackblitz. It is an app with a root component, which renders a mid-layer component. The mid-layer component renders itself a few times, and in the end, it renders a target component. The target component has a state which is going to be changed. Also, to visualise the change detection processes, I added the visualizeCd getter and put it in the template. It outputs an empty string, but it also uses console.log to show that the template was accessed. The mid-layer components output the fact of access and mark it with their depth. The target component does the same and marks it, too. Those getters are needed to understand what exact components were accessed.

In that example, I am trying to get closer to a “real-world” application with many components. In real-world applications, you usually have tons of them. They are like a tree with a lot of branches and leaves. Angular has such a tree under the hood and it uses it.

There is a great tool by Matthieu Riegler which helps understand how change detection works in different modes. It shows you what components are checked. Use the right branch and run the mark for check and/or signal change in that component to see the difference:

You can notice flags like HasChildViewsToRefresh or Dirty or Consumer dirty on views. To simulate the change detection run the AppRef.tick() after performing an action on the component. You will see how Angular checks views. What do those flags mean & how does Angular understand what to check? Let’s break it down. Let’s check how it works now.

How does change detection work now?

Here, I am giving a brief & big-picture explanation of how it works. That is because you can write more than one article about the change detection mechanism itself. There are several articles about it, so you can check them if interested (example). In the Angular internals, there are different structures to maintain all things which happen in the framework. There is a set of views. If you simplify, and I am doing so in that example, you might say you have one view for every component in the app. All of these views form a tree structure. Angular uses that structure to perform change detection. The view contains a lot of technical information about the component and uses that information while performing different operations. You can see the view as a container with a lot of information like the current state of inputs, the template, the directives, the current binding state, etc. Angular operates with those views, not components.

There are two parts to change detection. The first one is that change detection should be started somehow. That is when the zone.js comes into play. It tracks all that fancy async browser stuff (like XHR, DOM events, setInterval, setTimeout, etc.) and allows you to get a notification about something async happens. Angular has the NgZone module that uses zone.js for tracking such async interactions. When something async happens, the change detection is kicked off and Angular traverses the tree of views. You can find that code in Angular’s core. It starts the tree traversal. The second part is what should be checked during the change detection.

There are two strategies for how Angular performs change detection. It can be an OnPush or Default strategy. In the default case, it goes through the tree and accesses every view.

With OnPush optimisation, it works in a more granular way, but still, it is broad. In a real application, you don’t have a tree of views with one branch like in my relatively easy example. You have a lot of branches. The OnPush strategy allows the framework to cut whole branches of views and don’t check them. That allows applications to be way more performant.

There are two ChangeDetection strategies. In the default case, every view is checked during change detection. In the OnPush case, only marked views are checked during change detection

In the OnPush strategy, only “marked” views are getting checked. There are a few ways to mark components for check: an input binding changes, an event listener fires, or the markForCheck from the ChangeDetectoRef is called.

To mark a component for update in the OnPush you need to change an input, fire an event (or emit from an output), or call markForCheck (AsyncPipe does the same) manually

In my example, I am using the OnPush strategy, so actually, the app is quite optimised anyway. I want to compare this approach with a new one which is possible with signals. I use the markForCheck approach in my example for demonstration purposes. You can see it in the ngOnInit of the TargetComponent:

@Component({
selector: 'app-target',
standalone: true,
template: `state: {{ state }} {{ visualizeCd }}`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TargetComponent {
state = 0;
private cdr = inject(ChangeDetectorRef);

ngOnInit() {
setTimeout(() => {
console.log('>> change state');
this.state += 1;
// 👇
this.cdr.markForCheck();
}, 3000);
}
get visualizeCd() {
console.log('>> cd happened in target');
return '';
}
}

That markForCheck call marks the current view and all its parents till the root view for a check. Let’s visualise that. The current root & mid-layer components template are:

<!-- app-root -->
<app-mid />

<!-- app-mid -->
{{ visualizeCd }}
@if (depth < 5) {
<app-mid [depth]="depth + 1" />
} @else {
<app-target />
}

That gives you a tree like that:

What happens if you change the root component to the following?

<!-- root -->
<app-mid />
<app-mid [logicIsDisabled]="true" />

Let’s imagine the app-mid component has such an input and passes it down to the children until the target component. If that input is true, the state in the target component is not changed at all and the views will not be marked for check. The tree of views will look like this:

Now, the second branch has the changes disabled. Let’s check what happens when the markForCheck is called after the state change. The app-target view is marked for a check. Also, it marks for checking all parents' views till the root view. Here is what happens:

Now, since the setTimeout is an async operation and the zone.js tracks it. The Angular kicks off the change detection and checks only the green views.

What happens during change detection exactly? Every view contains a reference to a template function of the component. Every component has a template. During the build, those components are compiled into JavaScript functions. Here is an example of that function (from some README in Angular’s code):

function(rf: RenderFlags, ctx: MyApp) {
if (rf & RenderFlags.Create) {
ɵɵelementStart(0, 'div');
ɵɵtext(1);
ɵɵelementEnd();
}
if (rf & RenderFlags.Update) {
ɵɵproperty('title', ctx.name);
ɵɵadvance(1);
ɵɵtextInterpolate1('Hello ', ctx.name, '!');
}
...
}

Now, to perform a change detection, the Angular visits every view and if it is marked for check, it executes the template function of that component. Now, it compares new values with previously calculated values and if there is a difference, Angular renders changes.

To perform change detection Angular traverses the views’ tree and executes template function on each component

Let’s check what happens in the demo I provided before and you will understand why the visualizeCd helper was needed:

You can see that the cd happened log appears a lot of times before the change state log. It appears for every instance of the MidLayerComponent and once for the TargetComponent. After the change state, you can see that the log appears the same amount of times. That means that the visualizeCd was called on absolutely every component. Does it make sense now? That correlates with the above. The whole branch of views was marked for the check. The Angular checked every component by executing the template function for every view.

The template is executed on absolutely every marked view in the OnPush case

That is the current change detection world. Don’t forget about the playground for checking how the change detection works I have you above. You can check what is happening with OnPush + compare it to Angular Signals. Now, let’s review what is happening with Angular Signals and why.

How does it change with Angular Signals?

Let’s proceed to the second demo, where I use signals instead of simple variables. The code of the target component is slightly changed:

@Component({
selector: 'app-target',
standalone: true,
template: `state: {{ state() }} {{ visualizeCd }}`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TargetComponent {
state = signal(0);

ngOnInit() {
setTimeout(() => {
console.log('>> change state');
// 👇 no markForCheck
this.state.update((v) => v + 1);
}, 3000);
}

get visualizeCd() {
console.log('>> cd happened in target');
return '';
}
}

That is a very simple change, but that gives a lot 🤩! You can see that now the state instance member was changed to a signal. It was a simple variable before and it became a signal. Also, in the ngOnInit function I use this.state.update instead of simple this.state += 1. Note that there is no markForCheck call now. Also, in the template I used state(). If you want to fetch the signal value, you need to call a function. That code works like a charm.

Let’s consider the following while thinking about migrating the example to signals:

  • âť“ What happens when you put a signal into an Angular component’s template?
  • âť“What happens when a signal which was used in a template is changed?
  • âť“How the global change detection propagation mechanism is changed?

Let’s break down what is technically happening with that signal in the template. This signal is a part of a components template. From the previous section, you know that under the hood, Angular operates views & template functions. In the end, the state: {{ state() }} {{ visualizeCd }} template will become a template function like this:

function TargetComponent_Template(rf, ctx) {
if (rf & 1) {
ɵɵtext(0);
}
if (rf & 2) {
👇
ɵɵtextInterpolate2("state: ", ctx.state(), " ", ctx.visualizeCd, "");
}
}

The rf & 1 stands for the creation and the rf & 2 part stands for the update. That means that it is executed during any update. So, that is a basic JavaScript function. That must be called from somewhere. That happens in the executeTemplate function, which is called from the refreshView function. Let’s take a closer look:

// render3/instructions/change_detection.ts
export function refreshView<T>(tView: TView, lView: LView, templateFn: ComponentTemplate<{}>|null, context: T) {
// ...
let prevConsumer: ReactiveNode|null = null;
let currentConsumer: ReactiveLViewConsumer|null = null;
if (!isInCheckNoChangesPass && viewShouldHaveReactiveConsumer(tView)) {
// 👇👇👇
currentConsumer = getOrBorrowReactiveLViewConsumer(lView);
prevConsumer = consumerBeforeComputation(currentConsumer);
}
try {
// ...
if (templateFn !== null) {
// 👇👇👇
executeTemplate(tView, lView, templateFn, RenderFlags.Update, context);
}
// ....
} finally {
if (currentConsumer !== null) {
// 👇👇👇
consumerAfterComputation(currentConsumer, prevConsumer);
maybeReturnReactiveLViewConsumer(currentConsumer);
}
leaveView();
}
}
// render3/reactive_lview_customer.ts
export function getOrBorrowReactiveLViewConsumer(lView: LView): ReactiveLViewConsumer {
return lView[REACTIVE_TEMPLATE_CONSUMER] ?? borrowReactiveLViewConsumer(lView);
}
function borrowReactiveLViewConsumer(lView: LView): ReactiveLViewConsumer {
const consumer: ReactiveLViewConsumer =
freeConsumers.pop() ?? Object.create(REACTIVE_LVIEW_CONSUMER_NODE);
// 👇
consumer.lView = lView;
return consumer;
}
const REACTIVE_LVIEW_CONSUMER_NODE: Omit<ReactiveLViewConsumer, 'lView'|'slot'> = {
...REACTIVE_NODE,
// 👇
consumerIsAlwaysLive: true,
consumerMarkedDirty: (node: ReactiveLViewConsumer) => {
// 👇
markAncestorsForTraversal(node.lView!);
},
consumerOnSignalRead(this: ReactiveLViewConsumer): void {
this.lView![REACTIVE_TEMPLATE_CONSUMER] = this;
},
};typ

There is a lot of code, but pay attention to the lines above. currentConsumer = getOrBorrowReactiveLViewConsumer(lView) gets the existing lView[REACTIVE_TEMPLATE_CONSUMER] or creates a new reactive node based on the REACTIVE_LVIEW_CONSUMER_NODE structure. Also, note that the lView is added to the created consumer. That is an essential thing because it will be used later.

Every view has a reactive node associated with it

The prevConsumer = consumerBeforeComputation(currentConsumer); should be familiar to you if you read the previous articles. That sets the current activeConsumer state with the currentConsumer. After that the executeTemplate is invoked and the template function in it is executed. In the template function, there is a ctx.state() , which means accessing the signal. Now, the producerAccessed function will be called in which the link between to reactive nodes will be created. Please check the previous articles, where I explain that in detail. It was in the Lazy evaluation & automatic dependency graph section in the Deep dive into the Angular Signals: Part 1. Let’s check it briefly because you might now read the previous articles, and if yes, the repetition is good for learning anyway:

export function producerAccessed(node: ReactiveNode): void {
// ...
if (activeConsumer.producerNode[idx] !== node) {
// We're a new dependency of the consumer (at `idx`).
activeConsumer.producerNode[idx] = node;
// If the active consumer is live, then add it as a live consumer. If not, then use 0 as a
// placeholder value.
// 👇
activeConsumer.producerIndexOfThis[idx] =
consumerIsLive(activeConsumer) ? producerAddLiveConsumer(node, activeConsumer, idx) : 0;
}
activeConsumer.producerLastReadVersion[idx] = node.version;
}
function consumerIsLive(node: ReactiveNode): boolean {
return node.consumerIsAlwaysLive || (node?.liveConsumerNode?.length ?? 0) > 0;
}

In short, that method changes the state of the activeConsumer (the consumer in the component’s view) node and registers the currently accessed node (the state signal in the TargetComponent) into it. Also, if the activeConsumer is live the producerAddLiveConsumer will be called, which will add the activeConsumer to the liveConsumersNode array of the state signal. The activeConsumer is live because of the configuration of the reactive node that was created before executing the component’s template. Check the REACTIVE_LVIEW_CONSUMER_NODE definition in the previous code blocks.

The view’s reactive node becomes live consumer of every signal accessed in the component’s template. It is very similar to effect or computed signal

So, walk through all of that again. What happens with the signal in the component's template:

  1. Before executing the template, Angular creates or gets the existing view’s reactive consumer
  2. It sets it as an activeConsumer in case there will be signals in the template
  3. Angular executes the template and it accesses the signal
  4. Internally, the dependency is built between the view’s consumer and the state signal
  5. The value of the signal is returned to the template function so it can output it in the browser

When the template is executed the dependencies are built between all signals accessed in the template and the view’s reactive node

How does Angular know about changes in signals?

That is because, after the first execution of a template function, the dependency was set up between that signal and the view’s consumer. It allows reactivity system nodes to communicate. When you update a signal using a set or update methods, the signalSetFn function is called internally. That leads to the signalValueChanged call, which leads to the producerNotifiesConsumers call. Let’s get a closer look:

export function producerNotifyConsumers(node: ReactiveNode): void {
if (node.liveConsumerNode === undefined) {
return;
}
// Prevent signal reads when we're updating the graph
const prev = inNotificationPhase;
inNotificationPhase = true;
try {
for (const consumer of node.liveConsumerNode) {
if (!consumer.dirty) {
// 👇
consumerMarkDirty(consumer);
}
}
} finally {
inNotificationPhase = prev;
}
}
export function consumerMarkDirty(node: ReactiveNode): void {
// 👇
node.dirty = true;
producerNotifyConsumers(node);
// 👇
node.consumerMarkedDirty?.(node);
}

The function goes through the liveConsumerNode array of the node. If it is dirty, the consumerMarkDirty function is called. It sets the node as dirty, calls the producerNotifyConsumers recursively and calls the consumerMarkedDirty method of the node. Setting the node as dirty & the consumerMarkedDirtymethod are the most important right now. The method is defined in the REACTIVE_LVIEW_CONSUMER_NODE definition. It calls the markAncestorsForTraversal function with the node.lView parameter. Here is why the lView was needed in the reactive node. It was assigned in the borrowReactiveLViewConsumer from the above. Let’s check the marking function:

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];
}
}

That function modifies the FLAGS of every view from the current up to the root view. It is almost the same as what is happening with the markForCheck call, but the flag is different. Let’s compare it. The markForCheck method of ChangeDetectorRef leads to the markViewDirty utility:

export function markViewDirty(lView: LView): LView|null {
while (lView) {
// 👇
lView[FLAGS] |= LViewFlags.Dirty;
const parent = getLViewParent(lView);
// Stop traversing up as soon as you find a root view that wasn't attached to any container
if (isRootView(lView) && !parent) {
return lView;
}
// continue otherwise
lView = parent!;
}
return null;
}

It does almost the same thing as the markAncestorsForTraversal , but the flag is different.

So, let’s confirm what happens when the signal is changed:

When the signal, which is used in a component’s template, is changed, it notifies the view’s reactive live consumer about the change. That leads to marking that consumer as dirty. Also, it marks all its ancestors until the root with HasChildViewsToRefresh flag

Now, as you already know, the change detection mechanism has two parts. The first part is needed to determine what views should be checked. The second part is traversing the tree of views. Let’s check the second part. The second part is kicked off by zone.js.

The global top-down change detection propagation is still kicked of by zone.js

How is the tree traversal mechanism changed to interact with the reactivity system?

How does the reactivity system allow Local Change Detection?

Let's check what logs the second example based on signals:

You can see that after the change, there is only one log. It looks like that only one template function of the TargetComponent was executed. That is exactly what happened. That is possible because of the recent changes in Angular with components that use signals. In the previous section, I decomposed everything to the point that you could clearly see that when the signal changes, its view and all parent views till the root are marked with a special flag. Now, that flag will be used to detect if a component should be checked or not or, in other words, if the template function of a view should be executed. Let’s see how that works in detail.

The state of a signal was changed in a function that was passed to a setTimeout call. That is asynchronous operation, which means that zone.js is aware of that action. Angular’s core has a mechanism that tracks such async operations and triggers the global change detection mechanism after them. That happens in our examples, too. By the way, it does not matter if you use signals or not, zone.js triggers the global change detection. That might change in the future with signals: true components, but they are not live yet. So, Angular starts to traverse the view’s tree one by one from top to bottom. Now, remember the markAncestorsForTraversal. It set up the HasChildViewsToRefresh flag and mark the target component view as dirty. One of the ways you can simply try to find this flag in the code and see where it is used. Another way is you can put a debugger statement into the visualizeCd getter in the TargetComponent. Let’s check the call stack:

You can see familiar refreshView and executeTemplate functions. You also can see the TargetComponent_Template function. It is the template function of our target component. Also, there are a lot of functions like detectChanges* which are part of the change detection mechanism. The magic must happen in one of those functions. You can observe what happens there, you can open the demo and put the debugger statement to the visualizeCd getter by yourself. Let’s try to search that flag in the code. By the way, I am using the 21741384f4 commit hash for all examples. The code changes, so to be consistent and to have all links alive, I am showing everything from one specific commit. I was searching by .HasChildViewsToRefresh query and I was able to find two files: view_utils.ts and instructions/change_detection.ts. The first one is utils, and the second one is about the change detection process. We definitely wanna check the second one. Also, the first one is already familiar because the markAncestorsForTraversal util is from there. The HasChildViewsToRefresh is in two functions: the detectChangesInView and detectChangesInEmbeddedViews. The second function is called from the first one. Let’s check them:

function detectChangesInView(lView: LView, mode: ChangeDetectionMode) {
const isInCheckNoChangesPass = ngDevMode && isInCheckNoChangesMode();
const tView = lView[TVIEW];
const flags = lView[FLAGS];
const consumer = lView[REACTIVE_TEMPLATE_CONSUMER];
let shouldRefreshView: boolean =
!!(mode === ChangeDetectionMode.Global && flags & LViewFlags.CheckAlways);
shouldRefreshView ||= !!(
flags & LViewFlags.Dirty && mode === ChangeDetectionMode.Global && !isInCheckNoChangesPass);
shouldRefreshView ||= !!(flags & LViewFlags.RefreshView);
// Refresh views when they have a dirty reactive consumer, regardless of mode.
// 👇
shouldRefreshView ||= !!(consumer?.dirty && consumerPollProducersForChange(consumer));

if (consumer) {
consumer.dirty = false;
}
lView[FLAGS] &= ~(LViewFlags.HasChildViewsToRefresh | LViewFlags.RefreshView);
if (shouldRefreshView) {
// 👇
refreshView(tView, lView, tView.template, lView[CONTEXT]);
} else if (flags & LViewFlags.HasChildViewsToRefresh) {
// 👇
detectChangesInEmbeddedViews(lView, ChangeDetectionMode.Targeted);
const components = tView.components;
if (components !== null) {
// 👇
detectChangesInChildComponents(lView, components, ChangeDetectionMode.Targeted);
}
}
}

function detectChangesInEmbeddedViews(lView: LView, mode: ChangeDetectionMode) {
for (let lContainer = getFirstLContainer(lView); lContainer !== null;
lContainer = getNextLContainer(lContainer)) {
lContainer[FLAGS] &= ~LContainerFlags.HasChildViewsToRefresh;
for (let i = CONTAINER_HEADER_OFFSET; i < lContainer.length; i++) {
const embeddedLView = lContainer[i];
detectChangesInViewIfAttached(embeddedLView, mode);
}
}
}

Actually, those functions explain everything. Those functions are called recursively on absolutely every view in the app. When Angular performs the change detection process, it goes through every view and calls that code. From previous sections, you know that in case of signal changes, the views were marked with the HasChildViewsToRefresh flag. Also, every view has a reactive consumer that can be dirty. What is happening there? Let’s break it down.

I am trying (I am trying really hard, guys) to simplify things. Frankly speaking, Angular has two types of views and it uses them both: tView and lView. To simplify, let's focus on known flags for now and I omit some parts of the detectChangesInView function. Because this article is not about change detection.

At first, the function detects if the view should be refreshed. It checks different flags of the view and mode. Let’s check those lines:

// ...

shouldRefreshView ||= !!(
👇
flags & LViewFlags.Dirty && mode === ChangeDetectionMode.Global && !isInCheckNoChangesPass);

shouldRefreshView ||=
👇
!!(consumer?.dirty && consumerPollProducersForChange(consumer));

// ...

The first expression is checking if the view has the LViewFlags.Dirty flag and the mode. I will simplify the mode part in that article. In the case of OnPush + markForCheck mode, you usually have ChangeDetectionMode.Global. In the case of the signals mode and granular update, you will have ChangeDetectionMode.Targeted. The flags & LViewFlags.Dirty is a bitwise operation and that mechanism is used in Angular to perform blazing fast checks. The first expression is about the Dirty flag and I mentioned that before it was used in the markForCheck method of the ChangeDetectorRef. So, if it was marked for check it would have been true, but that is not the case right now.

Angular executes the template function if the view is marked with the RefreshView flag. That a case for the markViewDirty call (the markForCheck of the ChangeDetectorRef).

The second expression checks if the view’s reactive consumer is dirty. In my second example with signals, the view’s reactive node was marked as dirty. It happened when the state signal was changed, the view’s reactive node was notified and it became dirty. So when Angular traverses to that view, this check will return true on that view.

Angular executes the template function if the reactive node of the view is marked as dirty. That happens when any of the signals which are used in a template was updated because of a reactive dependency graph.

The consumerPollProducersForChange(consumer) part is needed in case of a computed signal, for example. From the first part of the deep dive, you might remember (check it out if you have not yet; it is cool, and you will make me happy :)) that there can be complex cases. Let’s check that part:

lView[FLAGS] &= ~(LViewFlags.HasChildViewsToRefresh | LViewFlags.RefreshView);

That is a bitwise operator, too. It removes the HasChildViewsToRefresh and RefreshView flags from the view. After that, you can see an if {} else {} condition. Let’s check it:

if (shouldRefreshView) {
refreshView(tView, lView, tView.template, lView[CONTEXT]);
} else if (flags & LViewFlags.HasChildViewsToRefresh) {
detectChangesInEmbeddedViews(lView, ChangeDetectionMode.Targeted);
const components = tView.components;
if (components !== null) {
detectChangesInChildComponents(lView, components, ChangeDetectionMode.Targeted);
}
}

In the case when the shouldRefreshView is true, it goes to the refreshView method and executes the template. In our example, it was done when the state of the state signal was updated in the target component.

Or else it checks if the view has the HasChildViewsToRefresh flag. This flag is set up in the markAncestorsForTraversal function. If yes, it proceeds to check the children without executing the template function. Both functions in the else if branch will lead to the detectChangesInView function again. It works recursively and will be invoked on each child of the current view and so on.

Let’s wrap it up. How does this work in the case when a signal is updated in a component:

  1. A signal is updated in a component as a part of the async operation like setTimeout
  2. Angular kicks off the global change detection from top to bottom
  3. As a part of it, the detectChangesInView is called on each view in the views tree in the app
  4. It checks if the view’s consumer is dirty or its dependencies have changes and executes the template if that is true
  5. If not, it checks if the view has the HasChildViewsToRefresh flag on it, which means that Angular has to proceed to children to check them. It does not execute the view’s template in that case. It skips it.
  6. Angular does not execute template functions on each view in case the view was marked because of the signal change.
  7. Also, note that this function does not go to the child views in case of Dirty flag. It just stops execution of the detectChangesInView and exits. That is how branches of views are cut in the case of markForCheck approach.

That algorithm is performed on every view in the tree. In the case when a signal was changed in the

The key takeaway:

Change detection works more optimally in the case of signals because Angular does not execute template functions on every view in the marked set of views. It does it granularly. It executes template functions only for views which reactive nodes are dirty.

A gentle but significant note

When the component uses OnPush strategy, there are a few ways to mark views for check. They are used quite often in apps, so the components can communicate with each other. Also, DOM events like (click) goes there, too. There is a code that marks the view and its parents as dirty in case of outputs. On the other hand, there are many things WebSockets, Server-Side Events, communication through services, and timers. So, while writing your apps, consider this detail. Let’s see what the Angular team suggests to us in future releases. According to RFC, it looks like there will be a lot more interesting tools in the future.

So, what did we discuss today:

  • âś… How does Angular build a Local Change Detection mechanism on top of Angular Signals?
  • âś… How does it integrate Angular Signals into the existing change detection mechanism?
  • âś… What happens when the signal is used in the components’ templates?

In conclusion, that is all that I wanted to say about the Local change detection mechanism, which is possible with Angular Signals. I think that is a significant step in Angular evolution and it was terrific 🚀 to decompose that part. Thank you Roman Sedov and Alex Inkin from AngularWave for reviewing it. That helps a lot! Stay tuned on my Twitter and see you next time!

--

--