DEV Community

Cover image for Exploring Angular's Change Detection: In-Depth Analysis
Jarosław Żołnowski
Jarosław Żołnowski

Posted on • Edited on

Exploring Angular's Change Detection: In-Depth Analysis

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:

  1. 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.

  2. 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();
    });
  },
});
Enter fullscreen mode Exit fullscreen mode

And traverse the view tree to detect and propagate any changes:

for (let {_lView, notifyErrorHandler} of this._views) {
  detectChangesInViewIfRequired(
    _lView,
    notifyErrorHandler,
    isFirstPass,
    this.zonelessEnabled,
  );
}
Enter fullscreen mode Exit fullscreen mode

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
);
Enter fullscreen mode Exit fullscreen mode

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]);
}
Enter fullscreen mode Exit fullscreen mode

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.

Click on Component D, which triggers an animation throughout the entire DOM tree from top bo the bottom

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();
 }
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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();
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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!;
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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;
 
}
Enter fullscreen mode Exit fullscreen mode

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
);
Enter fullscreen mode Exit fullscreen mode

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.

Click on Component D triggers an animation on the clicked element and all its ancestors, starting from the root component

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);
  
}
Enter fullscreen mode Exit fullscreen mode

making it go, "Hey, I'm dirty!"

for (const consumer of node.liveConsumerNode) {
  if (!consumer.dirty) {
    consumerMarkDirty(consumer);
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

Click on Component D, which triggers an animation only on itself

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.

Top comments (0)