Change Detection Fundamentals in Angular

Antonio Pekeljević
19 min readAug 9, 2023

Uncovering the magic behind Angular’s change detection and what we can do to improve our applications once we understand how change detection works.

Change Detection Fundamentals in Angular

One of the most “mysterious” things I found about Angular back when I started using it (and what made me angry a lot of times 😫) is it’s change detection. I was never 100% sure about what is happening behind the scenes which has led me to many unexpected behaviors of the application I was working on. Then it started making sense to me why developers refer to Angular as “hard to learn” or even “bad” 🙊. Angular as a framework really started clicking for me once I decided to spend some time and actually learn how change detection works.

This blog post will (try to) be what everyone who is an Angular developer should understand as soon as possible if they want to make their developing experience less painful and really “soulbind” with the framework.

Understanding the change detection was beneficial to me so I will give my best to make this a one stop article for my fellow developers. Let’s start!

I want to give a huge thank you to Matthieu Riegler who dedicated his time to help me improve this article by providing his vast Angular knowledge.

Content:

  1. Purpose of Change Detection in Angular
  2. Overview
  3. Important methods for developers
  4. What are Zones and how do they work?
  5. Change Detection Strategies
  6. Debugging Change Detection Errors (NG0100)
  7. Optimizing Change Detection in Angular Applications
  8. Recap with Source Code
  9. Future Change Detection Mechanism: Signals

1. Purpose of Change Detection in Angular

The purpose of change detection is essentially the possibility to make changes of components’ properties (i.e. data model) reflected in the DOM. It’s what makes the dynamic variables “up to date” in the template code of a component. Without it, properties would only exist in the component code but in the template those would never be able to get updated even if we change them in the script. I am probably stating the obvious but I just want to make everything clear from the start.

2. Overview

In order to understand how change detection works on a deep level it would be beneficial if we first have to start from the surface. 🌎

With that being said and before we get to the actual change detection topic, let’s first see what Angular is actually checking for changes. You might think that Angular checks the DOM directly or has some internal data structure that keeps track of changes before actually updating the DOM. If you were guessing the latter then you would be right. Instead of checking the DOM for changes directly, which is quite expensive (memory heavy), Angular keeps track of changes by having something called the View Hierarchy Tree. This tree represents hierarchical relations between Views. Now let’s define what exactly are Views.

📕 Official definition from Angular docs:

The smallest grouping of display elements that can be created and destroyed together.

Properties of elements in a view can change dynamically, in response to user actions; the structure (number and order) of elements in a view can’t. The structure of elements can be changed by inserting, moving, or removing nested views within their view containers.

Simply said, each component has one View that belongs to it which keeps all the necessary information from the template and data model. The View contains an array of references to all element nodes from that component’s template and also all variables that are used in the template and in the belonging data model.

With the existence of Views, Angular is able to compare the current state of the data model to its previous state (that is saved on the View itself) in order to identify changes.

ApplicationRef.views example with two components

The View Hierarchy Tree should now be easier to understand. It’s a tree structure of component Views that Angular will use to search for a change. 🌳

To prove this, we can inspect the ApplicationRef of an Angular application and we will find usually something that looks like this:

Views property on the ApplicationRef object

If you start exploring the various properties of in _views you will see that you can find everything that exists in our app (if you have so much time). Main thing that we need to know, in order to stay on topic, is that exactly this is what Angular uses to detect changes.

  • We change the property value
  • Change detection runs
  • Property value is compared to the value that exists on the View
  • If the values differ, the value on the View is updated and template rendering is triggered using the new value

At this point, you might be asking yourself:
But how does Angular know when to trigger the change detection?

So on a “detection invoking” event, Angular will compare old and new values and, if a change is detected, the view will render the corresponding template again using the new values.

For example, if we change the an `@Input` property in a component the following internal method will be executed:

override setInput(name: string, value: unknown): void {
const inputData = this._tNode.inputs;
let dataValue: PropertyAliasValue|undefined;
if (inputData !== null && (dataValue = inputData[name])) {
this.previousInputValues ??= new Map();
// Do not set the input if it is the same as the last value
// This behavior matches `bindingUpdated` when binding inputs in templates.
if (this.previousInputValues.has(name) &&
Object.is(this.previousInputValues.get(name), value)) {
return;
}

const lView = this._rootLView;
setInputsForProperty(lView[TVIEW], lView, dataValue, name, value);
this.previousInputValues.set(name, value);
const childComponentLView = getComponentLViewByIndex(this._tNode.index, lView);
markViewDirty(childComponentLView);
}
// I removed the else case for better readability
}

We can see that if the input value is different than the previous, the method will call markViewDirty(view) (which is a private, internal method) on the View which later results in a new render of the template thus we end up with the updated DOM elements.

Without digging too deep, this is essentially how the change detection happens.

Triggering of the change detection cycle can happen due to one of following reasons:

  • Property marked as an `@Input` has changed.
    (checked by Angular internally like shown in the code sample above)
  • Manual trigger (if developer calls any methods described in chapter 3. )
  • Using async calls such as setTimeout, event listeners, promises and so on. This is achieved by a helper library upon which you have probably stumbled already called Zone.js.

What Zone.js does is basically “monkey-patching” the native browser’s async methods to trigger the change detection so that we don’t have to trigger it manually. That is because in most cases there are changes that happened after an async operation.

Here is an extremely simplified example of how Zone.js monkey-patches the setTimeout method just to give you an idea:

window.setTimeout = function setTimeout(callback, time) {
// triggers change detection
const callbackWithCd = () => {
callback();
this.invokeChangeDetection();
}

return setTimeoutOriginal(callbackWithCd, time);
}

I will explain how the Zone.js exactly works and where it fits in the whole Angular architecture in a later chapter since it’s quite a topic.

⚠️It is important to understand that View is what DOES the change detection i.e. updating and DOM rendering. Zone.js on the other side only TRIGGERS the change detection on async events by calling methods from the View (ultimately).

As you can see in the source code 📑, View is the one that extends the ChangeDetectorRef thus has all methods for invoking change detection.

export abstract class ViewRef extends ChangeDetectorRef {
/**
* Destroys this view and all of the data structures associated with it.
*/
abstract destroy(): void;

/**
* Reports whether this view has been destroyed.
* @returns True after the `destroy()` method has been called, false otherwise.
*/
abstract get destroyed(): boolean;

/**
* A lifecycle hook that provides additional developer-defined cleanup
* functionality for views.
* @param callback A handler function that cleans up developer-defined data
* associated with a view. Called when the `destroy()` method is invoked.
*/
abstract onDestroy(callback: Function): void;
}

On the other side, here is (source 📑) how the change detection is triggered by the NgZone (hopefully easier understand with a diagram):

NgZone Change Detection Triggering Diagram

Essentially, when the onMicrotasksEmpty listener is invoked by a patched async event, the ApplicationRef.tick() runs a check over the whole application tree to see which Views are requiring an update and updates them.

3. Important methods for developers

When talking about change detection it’s necessary to mention methods that a developer can practically use in order to use the change detection work for you instead against you. It’s possible to trigger the change detection and skip it as well. Here are some of the most important methods all developers should understand:

  • ApplicationRef.tick()
    - runs the change detection on the whole application’s View Tree starting with the root View
  • ChangeDetectorRef.detectChanges()
    - performs a local change detection i.e. detects changes in the current View and all children Views
  • ChangeDetectorRef.markForCheck()
    - marks the current View and it’s ancestors dirty which will be checked in the next change detection cycle. (next detection cycle is commonly the next tick)
  • NgZone.run()
    - runs the code in the passed callback function inside the Angular zone i.e. all changes will be picked up by the change detection
  • NgZone.runOutsideAngular()
    - runs the code in the callback function outside the Angular zone which enables preventing the change detection from being triggered

4. What are Zones and how do they work?

Now that we have a bit of knowledge about Views we can start talking about Zones. 🔲

Angular divides the application into zones to efficiently track and detect changes. The main goal of this approach is to reduce the frequency of change detection checks, making the application more performant.

Before we proceed first let’s make the difference between NgZone and Zone.js clear.

So, NgZone and Zone.js are related concepts in Angular, but they serve different purposes. Let’s understand the differences between them:

  1. Zone.js:
  • Zone.js is a third-party library used by Angular to implement Zones, which are execution contexts that allow tracking and intercepting asynchronous operations.
  • It is responsible for capturing and wrapping asynchronous tasks (i.e. monkey-patching) such as timers, HTTP requests, and event handling, ensuring they run within specific zones. You can see the complete list of all API’s that are patched by Zone.js at this Angular location in the Angular source code📑.
  • Zone.js enables Angular to trigger change detection when asynchronous tasks complete, keeping the application’s user interface in sync with the data model.

2. NgZone:

  • NgZone is an Angular service that acts as the root zone in an Angular application. — source 📑
  • It provides a way to run code outside Angular’s change detection zone, allowing developers to explicitly trigger or suppress change detection as needed.
  • NgZone is particularly useful when dealing with third-party libraries or other parts of the application where you want to control when change detection should occur.
  • It offers two main methods: run() and runOutsideAngular(). The run() method executes code within Angular's change detection zone, while runOutsideAngular() runs code outside of it.

As we just mentioned, NgZone is the root zone of the Angular application within which all change detection is getting triggered but it is also possible to create custom zones by using the method Zone.fork().

Root Zone (NgZone) of the whole Angular Application

NgZone is the root zone that encompasses the entire Angular application. It is responsible for triggering change detection whenever there are async tasks or browser events (such as timers, HTTP requests, or user interactions) that could potentially cause changes in the application state.

Here is a simplified example of a click event execution flow:

Simplified click event execution flow in Angular

One of the main mechanisms and reasons of using NgZone is the microtasks queue (source 📑). With every async operation, the callback passed (i.e. the function that we want to execute), is pushed to the microtasks queue as a task. The callbacks in the queue are executed until there are no tasks left and at that time a onMicrotaskEmpty event is emitted as explained in chapter 2 previously. Of course, NgZone is much more complex than that and contains many other properties and methods.

What are Microtasks?

Microtasks in JavaScript are tiny tasks that are executed immediately after the current task but before any rendering updates. They’re important for handling time-sensitive operations like promise resolutions. While the event loop processes bigger tasks called macrotasks (more commonly called just Tasks), microtasks ensure that high-priority tasks are dealt with as soon as possible. For instance, when a promise is resolved, its associated callbacks are scheduled as microtasks, guaranteeing immediate execution before any UI changes occur.

This is a nice animation to give you an idea: (original link 🔗)

Event Queue with Microtasks Animated Example

📗 A great place for a much more in detail explanation is this article by Jake Archibald 🔗.

Zone.js Disabled (noopzone)

You have the choice to not rely on Zone.js for Angular to function. Instead, you can choose to manually trigger the change detection.

Here is how to get rid of Zone.js completely:

  1. We need to comment out or remove the following line. Most of the time, this entry is in either polyfill.ts or directly in the angular.json (under the polyfills options):
// import 'zone.js';

2. Bootstrap Angular with the noop zone in src/main.ts:

platformBrowserDynamic().bootstrapModule(AppModule, { ngZone: 'noop' })
.catch(err => console.error(err));

Done!

The ngZone: ‘noop’ switches to using the NoopNgZone (source 📑) which is basically, as the name says, a noop implementation of the NgZone, meaning that the patching of methods that is initially expected by using the regular NgZone doesn’t occur. All methods that would usually be patched are just plainly run.

Let’s look at this simple code:

// PARENT COMPONENT 

@Component({
selector: 'parent',
template: `
<child [name]="name"></child>
<br />
<button (click)="onClick()">click</button>
`,
})
export class AppComponent {
name = 'Angular';

constructor(private cdRef: ChangeDetectorRef) {}

onClick() {
this.name = 'Antonio';
this.cdRef.detectChanges(); <------ DETECT CHANGES
}
}
// CHILD COMPONENT

@Component({
selector: 'child',
template: '<span>{{ name }}</span>',
})
export class ChildComponent {
@Input()
name = '';
}

As you can see, the code is very simple and basically the parent component only passes the name valueto its child as an Input.

The name value is updated on a button click and a detectChanges method is called. As expected, the name value in the child component changed accordingly.

If, however, we remove the detectChanges call in the onClick method, the name value in the child component will not change, meaning that the fact that the name value is marked with an ‘@Input’ decorator doesn’t provide any special change detection.

Conclusion:
No Zone.js = no patched APis = no automatic tick = no CD fired ❌

I hope that the concept of Angular zones is more clear now and that we can move to the next topic!

5. Change Detection Strategies

The change detection strategies in Angular exist to optimize the performance of applications and avoid unnecessary change detection cycles. Change detection is a computationally expensive operation, especially in large applications with many components. By offering different strategies, developers have the flexibility to choose the most suitable approach based on their application’s requirements and complexity.

Default change detection is simple to use and works well for smaller applications with a limited number of components. However, as the application grows, the default strategy can lead to performance bottlenecks, as it checks all components on every event.

On the other hand, the OnPush change detection strategy encourages developers to use immutable data and be more explicit about when to trigger change detection. By using OnPush and immutable data structures, you can significantly reduce the number of change detection cycles, leading to better performance and a more responsive application.

So we can say that change detection strategies give developers the ability to fine-tune the performance of their Angular applications by selecting the most appropriate approach based on the application’s complexity and data handling requirements.

In Angular, change detection strategies are approaches that determine how Angular detects and propagates changes in the application’s data model to update the user interface. As already mentioned, there are two change detection strategies available in Angular: Default (also known as “CheckAlways”) and OnPush.

Default (CheckAlways) Change Detection Strategy:

  • This is the default change detection strategy in Angular. When you create a component without explicitly specifying a change detection strategy, it uses the default strategy.
  • With this strategy, Angular performs change detection for the entire component tree on every async event that is being listened to in the application. This means that even if other component’s properties have not changed, Angular will still run a check for changes in all those components and its children on every event.
  • The Default strategy is more straightforward to use but can be less efficient for complex applications, as it may trigger unnecessary change detection cycles and lead to (sometimes massive) performance issues.

OnPush Change Detection Strategy:

View Tree change detection run with OnPush strategy

👉Make sure to check this page out 🔗 for an even better understanding.
It’s a interactive demonstration of how change detection works in this case (and more).

  • With the OnPush strategy, only components marked as dirty will be checked.
  • In the OnPush strategy, Angular only checks for input reference changes. If the reference remains the same, Angular assumes that the data has not changed and skips the change detection for that component and its children.
  • When using OnPush, you need to be careful to only modify the component’s properties by creating new references (e.g. using immutable data structures) to trigger the change detection correctly.
    A great library that makes life easier when working with immutable data structures is Immer 🔗.
  • The OnPush strategy is more performant, especially for large and complex applications, as it reduces the number of change detection cycles and improves the overall application performance by also updating only some components of the tree.
  • The markForCheck() method marks all parents dirty and the next tick will check them for change detection.

⚠️Please note that markForCheck() doesn’t trigger the change detection but only marks the component and it’s parents to be checked in the next cycle. Most of the time this call is used internally as shown in AsyncPipe (source 📑).
We can see that in the source code📑 where this method ultimately calls markViewDirty() method:

export function markViewDirty(lView: LView): LView|null {
while (lView) {
// Flag is set here!
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;
}

To sum it up, when using OnPush strategy Angular will run change detection if:

  • ‘@Input’ is updated (by reference, not value!)
  • Change detection is run manually by using detectChanges() or markForCheck()
  • An event is fired from the template of a component or any of it’s children components (for example a (click) ).
    Note that setTimeout calls and any event listeners that are not from the template will not trigger a change detection.
  • If we use an Observable as an ‘@Input’ and we use the async pipe in the template. (because the async pipe subscribes to the observable and runs a change on every value change from the observable)

There are good examples of this strategy on this official documentation page. (not to repeat what already is explained better than I can)

6. Debugging Change Detection Errors (NG0100 error ❌)

Debugging change detection-related errors in Angular can ensure the smooth functioning and performance of your application. These are some ways you can use to debug change detection issues:

Use Angular DevTools:

  • Install the Angular DevTools browser extension for Chrome or Firefox.
  • Use the DevTools to inspect component trees, monitor change detection cycles, and check the component’s change detection strategy.

Enable Zone.js Long Stack Traces:

  • Zone.js provides the ability to enable long stack traces, which show the complete call stack, including asynchronous operations.
  • To enable long stack traces, add the following line in your main.ts file before bootstrapping the application:
import 'zone.js/dist/long-stack-trace-zone';

Check Input Property Updates:

  • Ensure that input properties are updated with new references whenever their values change, especially in OnPush components.
  • Avoid directly mutating input properties, and use immutable data structures for updates.

Review Component Lifecycle Hooks:

  • Pay attention to lifecycle hooks like ngAfterViewInit or ngOnChanges, as they can trigger change detection indirectly.
  • Make sure that these hooks are not unintentionally causing changes in the component’s state.

Apply some general debugging methods:

  • Use console logs, breakpoints, or logging frameworks to track the flow of code and check when components are being updated or change detection is being triggered.
  • If possible, create a minimal, isolated version of your application or component to reproduce the change detection error. This can help narrow down the cause.

Review Third-Party Libraries:

  • If you are using third-party libraries, check their documentation and verify if they are interacting well with Angular’s change detection mechanism.

When a value within an expression is altered after Angular completes its change detection process, Angular raises an ExpressionChangedAfterItHasBeenCheckedError. This particular error is triggered solely in Angular’s development mode.

In development mode (devMode), after every change detection cycle, Angular conducts a second change detection cycle to ensure that bindings remain unchanged. This precautionary step is taken to make sure that for a given data set, rendering stays consistent. Such inconsistencies may arise, for instance, when a method or getter produces different outcomes with each invocation or when a child component modifies values on its parent. If any of these situations occur, it indicates that the change detection process is not stable. Angular throws this error to guarantee that data in the view is always accurately reflected, thereby preventing unpredictable UI behavior or the possibility of an infinite loop. So please don’t “solve” this error by just running the production version of your app or just switching to OnPush (related issue 🔗) 😆

This error frequently arises when introducing template expressions or implementing lifecycle hooks like ngAfterViewInit or ngOnChanges. It is also common when dealing with loading statuses and asynchronous operations, or when a child component modifies its parent’s bindings.

7. Optimizing Change Detection in Angular Applications

Now that we know more about change detection, let’s see what more we can do to make our application even better!

  • As we have mentioned in previous chapters, Angular’s default change detection mechanism may become a performance bottleneck for large and complex applications. However, you can optimize the change detection process by implementing the OnPush change detection strategy. Consider also using the ChangeDetectorRef.detachView() method in combination with ChangeDetectorRef.detectChanges() to temporarily opt out of the change detection tree if necessary. Be very cautious when using this method though and make sure you call ChangeDetectorRef.reattach() after you are done with the isolated work.
  • Working with async data, such as Observables, can trigger unnecessary change detection cycles. To address this, utilize the async pipe that we also touched on before. By adopting the async pipe in your templates, you can minimize unnecessary detections and boost your application’s performance.
  • Complex template expressions can lead to performance issues as they are reevaluated during each change detection cycle. To optimize your application’s performance, simplify template expressions and avoid using complex logic within the templates. Instead, opt for component methods or getters to process data and return simple values that can be easily rendered. Using methods within the template is highly discouraged.
  • When using ngFor to iterate through a list of items, Angular recreates DOM elements each time the list changes, which can be slow. To improve efficiency, implement a trackBy function, helping Angular identify which items have changed and update only the necessary elements. This results in a more efficient rendering process and overall improved performance.

By implementing these optimization techniques, you can (sometimes significantly) enhance your Angular application’s change detection performance, providing a smoother and more responsive user experience. Who doesn’t want that!? 😻

8. Recap with Source Code 🔁

To quickly cover everything we learned:

  • Application boostrapping — source 📑
  • Initializing Zone.js with selected config (enabled or disabled) — source📑
  • Application listens for onMicrotasksEmpty (With Zone.js enabled) and triggers change detection — source 📑
  • All async events are patched by Zone.js and callbacks are pushed into the microtasks queue thus trigger the listener from previous step source 📑
  • With the option ChangeDetectionStrategy.OnPush automatic change detection is triggered only from template async calls — source 📑

9. Future Change Detection Mechanism: Signals

I was a bit unsure if I should write about Signals since the feature is not in its final form yet but it might give you an idea on what Angular could look like in the near future so I decided to go for it anyway. 😄

In Angular version 16 a new developer preview feature was rolled out called the Signals with an intention of creating a better, more optimized change detection system (that would eventually make Zone.js obsolete).

A Signal is a primitive that serves as a “wrapper” around a value of any type, which when invoked, returns the current value. You can imagine it like an Observable that has an active subscription already happening under the hood.

With signals we will be able to have automatic change detection on the component level without firing on the whole view tree (i.e. fine grained reactivity).

When a signal is updated using the set, update, or mutate method, its values in templates are updated while also marking the View to which the template belongs as dirty. A change detection cycle happens afterwards and the View is up to date. We can see that this is the line that ultimately marks the View dirty source 📑.

Please note that this is only the current implementation but the ultimate goal is to eventually opt-out from the traditional tree-based change detection.

It is important to note that only Signals which are used in a template will trigger a View refresh and the ones outside the templates (i.e. in functions) will not.

Angular change detection with Signals

If we look at how Angular did change detection before (with Zone.js), we can notice the massive improvement in the number of Views checked and refreshed as now only the View where a change happened, not its parent components nor children.

In the current state, the change detection still relies on Zone.js even if using Signals only. (under the hood Zone.js schedules a Microtask on a Signal update).
This is expected to change soon and change detection should become totally Zone-less.
Just be aware that going totally Zone-less is not possible at the moment with Signals. ❌

🆕If you are interested in how a Zone-less Angular app that only uses Signals looks like, you can check this experimental library by Michael Egger-Zikes.

If you read the whole article, I congratulate you on your determination and focus abilities! 😆

I hope that this article was beneficial to you and that your understanding of how change detection works has improved at least a little.

👋Have fun with Angular, feel free to X(tweet) me any time @antoniopkvc and subscribe here for more upcoming stuff!

--

--

Antonio Pekeljević

👨‍💻 I code for a living mostly in 🅰️ Angular and like to share what I learned!