How change detection will be replaced by signals in Angular and why it doesn't make sense to talk call it change detection anymore.
Published Dec 21, 2023

A change detection, zone.js, zoneless, local change detection, and signals story šŸ“š

Angular is a component-driven framework. And just like every other framework out there, it is supposed to show the data to the user and refresh the view when the data changes.

A UserCard component that shows user data in the template A UserCard component that shows user data in the template

Over time, we build more and more components and compose them together, and we may end up with a component tree like the one below. Component structure Component structure

But, how does Angular know when to refresh the view? How does it know when the data changes? How does it know when to run the change detection?

Change detection with synchronous code

Letā€™s start with a simple example. We have a component with a property name and a method changeName. When we click the button, we call the changeName method and change the name property.

@Component({
  template: `
    <button (click)="changeName()">Change name</button>
    <p>{{ name }}</p>
  `
})
export class AppComponent {
  name = 'John';

  changeName() {
    this.name = 'Jane';
  }
}

When we click the button, the changeName method is called, and because everything is wrapped by Angular, we can safely assume that after the name is changed, Angular can run some code to update the view (and everything will be in sync).

āš ļø Imaginary under the hood Angular code:

component.changeName();
// This code is run  Angular will run change detection for the whole component tree because we may have updated some data in a service that is used by other components
angular.runChangeDetection(); 

This works fine! But, most of the time when we change the data, we donā€™t do it synchronously. We usually make an HTTP request, or have some timers, or wait for some other events to happen before updating the data. And thatā€™s where the problems start.

Change detection with asynchronous code

Now, letā€™s say that we want to change the name after 1 second. We can do that with the setTimeout function.

@Component({
  template: `
    <button (click)="changeName()">Change name</button>
    <p>{{ name }}</p>
  `
})
export class AppComponent {
  name = 'John';

  changeName() {
    setTimeout(() => {
      this.name = 'Jane';
    }, 1000);
  }
}

When we click the button, the changeName method is called, and the setTimeout function is called. The setTimeout function will wait for 1 second and then call the callback function. The callback function will change the name to Jane.

And now, letā€™s add the same imaginary under-the-hood Angular code as before.

āš ļø Imaginary under the hood Angular code:

component.changeName(); // uses setTimeout inside

// this code is run immediately after the changeName method is called
angular.runChangeDetection();

Because of the call stack, the setTimeout callback will be called after the angular.runChangeDetection method. So, Angular ran the change detection but the name was not changed yet. And thatā€™s why the view will not be updated. And thatā€™s a broken application āš ļø. (In reality, itā€™s not, because we have šŸ‘‡)

Zone.js to the rescue

Zone.js has been around since the early days of Angular 2.0. It is a library that monkey patches the browser APIs and enables us to hook into the lifecycle of the browser events. What does that mean? It means that we can run our code before and after the browser events.

setTimeout(() => {
  console.log('Hello world');
}, 1000);

The code above will print Hello world after 1 second. But, what if we want to run some code before or after the setTimeout callback? You know, for business reasons šŸ˜„. A framework called Angular may want to run some code before and after the setTimeout callback.

Zone.js enables us to do that. We can create a zone (Angular does create a zone too) and hook into the setTimeout callback.

const zone = Zone.current.fork({
  onInvokeTask: (delegate, current, target, task, applyThis, applyArgs) => {
    console.log('Before setTimeout');
    delegate.invokeTask(target, task, applyThis, applyArgs);
    console.log('After setTimeout');
  }
});

To run our setTimeout inside the zone, we need to use the zone.run() method.

zone.run(() => {
  setTimeout(() => {
    console.log('Hello world');
  }, 1000);
});

Now, when we run the code above, we will see the following output.

Before setTimeout
Hello world
After setTimeout

And thatā€™s how zone.js works. It monkey patches the browser APIs and enables us to hook into the lifecycle of the browser events.

Zone.js + Angular

Angular loads zone.js by default in every application and creates a zone called NgZone. NgZone includes an Observable called onMicrotaskEmpty. This observable emits a value when there are no more microtasks in the queue. And thatā€™s what Angular uses to know when all the asynchronous code is finished running and it can safely run the change detection.

NgZone wraps every angular application today NgZone wraps every angular application today

Letā€™s look at the underlying code:

// ng_zone_scheduling.ts NgZoneChangeDetectionScheduler
this._onMicrotaskEmptySubscription = this.zone.onMicrotaskEmpty.subscribe({
    next: () => this.zone.run(() => this.applicationRef.tick())
});

What we see in the code above is that Angular will call the applicationRef.tick() method when the onMicrotaskEmpty observable emits a value. Whatā€™s this tick method šŸ¤”? Do you remember the runChangeDetection method from the imaginary under-the-hood Angular code? Well, the tick method is the runChangeDetection method. It runs the change detection for the whole component tree synchronously.

But now, Angular knows that all the asynchronous code has finished running and it can safely run the change detection.

tick(): void {
    // code removed for brevity
    for (let view of this._views) {
        // runs the change detection for a single component
        view.detectChanges(); 
    }
}

The tick method will iterate over all the root views (most of the time we have only one root view/component which is AppComponent) and run detectChanges synchronously.

Component Dirty marking

One other thing Angular does is that it marks the component as dirty when it knows something inside the component changed.

These are the things that mark the component as dirty:

  • Events (click, mouseover, etc.)

Every time we click a button with a listener in the template, Angular will wrap the callback function with a function called wrapListenerIn_markDirtyAndPreventDefault. And as we can see from the name of the function šŸ˜…, it will mark the component as dirty.

function wrapListener(): EventListener {
  return function wrapListenerIn_markDirtyAndPreventDefault(e: any) {
    // ... code removed for brevity
    markViewDirty(startView); // mark the component as dirty
  };
}
  • Changed inputs

Also, while running the change detection, Angular will check if the input value of a component has changed (=== check). If it has changed, it will mark the component as dirty. Source code here.

setInput(name: string, value: unknown): void {
  // Do not set the input if it is the same as the last value
  if (Object.is(this.previousInputValues.get(name), value)) {
    return;
  }
  // code removed for brevity
  setInputsForProperty(lView[TVIEW], lView, dataValue, name, value);
  markViewDirty(childComponentLView); // mark the component as dirty
}
  • Output emissions

To listen to output emissions in Angular we register an event in the template. As we saw before, the callback fn will be wrapped and when the event is emitted, the component will be marked as dirty.


Letā€™s see what this markViewDirty function does.

/**
 * Marks current view and all ancestors dirty.
 */
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;
}

As we can read from the comment, the markViewDirty function will mark the current view and all ancestors dirty. Letā€™s see the image below to better understand what that means.

Dirty marking component and its ancestor up to the root Dirty marking component and its ancestor up to the root

So, when we click the button, Angular will call our callback fn (changeName) and because itā€™s wrapped with the wrapListenerIn_markDirtyAndPreventDefault function, it will mark the component as dirty.

As we said before, Angular uses zone.js and wraps our app with it.

NgZone wraps Angular apps NgZone wraps Angular apps

After dirty marking to the top, wrapListenerIn_markDirtyAndPreventDefault fires and triggers zone.js

Event listeners notify zone.js Event listeners notify zone.js

Because Angular is listening to the onMicrotaskEmpty observable, and because the (click) registers an event listener, which zone has wrapped, zone will know that the event listener has finished running and it can emit a value to the onMicrotaskEmpty observable.

onMicrotaskEmpty fires when thereā€™s no microtask running anymore onMicrotaskEmpty fires when thereā€™s no microtask running anymore

onMicrotaskEmpty tells Angular itā€™s time to run the change detection.

Component binding refresh

The moment Angular runs the change detection it will check every component from top to bottom. It will go over all the components (dirty and non-dirty) and check their bindings. If the binding has changed, it will update the view.

cd-binding-refresh.webp

But, why does Angular check all the components šŸ¤”? Why doesnā€™t it check only the dirty components šŸ¤”?

Well, because of the change detection strategy.

OnPush Change Detection

Angular has a change detection strategy called OnPush. When we use this strategy, Angular will only run the change detection for a component that is marked as dirty.

First, letā€™s change the change detection strategy to OnPush.

@Component({
  // ...
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class UserCard {}

Letā€™s look at the graphic below to better understand how the change detection works with the OnPush strategy.

Some components now are marked as OnPush (and their children are implicitly OnPush components)

Letā€™s do the same thing as before. Click a button in the component and change the name.

First, we will have the Dirty Marking phase.

cd-on-push-click.webp

Then, the event listener will notify zone.js.

Event notifies zone.js Event notifies zone.js

When everything async has finished running, onMicrotaskEmpty will fire.

on-push-microtask-empty.webp

Now, Angular will run the tick method, and what it will do is traverse all components from top to bottom and check each component.

If the component is:

  • OnPush + Non-Dirty -> Skip
  • OnPush + Dirty -> Check bindings -> Refresh bindings -> Check children

onpush-refresh.webp

As we can see, by using OnPush we can skip parts of the tree that we know havenā€™t had any changes.

OnPush + Observables + async pipe

When we work with Angular, observables have been our number one tool to manage data and state changes. To support observables, Angular provides the async pipe. The async pipe subscribes to an observable and returns the latest value. To let Angular know that the value has changed, it will call the markForCheck method that comes from the ChangeDetectorRef class (the componentā€™s ChangeDetectorRef).

@Pipe()
export class AsyncPipe implements OnDestroy, PipeTransform {
  constructor(ref: ChangeDetectorRef) {}

  transform<T>(obj: Observable<T>): T|null {
    // code removed for brevity
  }

  private _updateLatestValue(async: any, value: Object): void {
    // code removed for brevity
    this._ref!.markForCheck(); // <-- marks component for check
  }
}

Iā€™ve written more about it here (create async pipe from scratch and understand how it works):

And what the markForCheck method does is that it will just call the markViewDirty function that we saw before.

// view_ref.ts
markForCheck(): void {
  markViewDirty(this._cdRefInjectingView || this._lView);
}

So, the same as before, if we use observables with async pipe in the template it will act the same as if we used the (click) event. It will mark the component as dirty and Angular will run the change detection.

data$ | async pipe marks component as dirty data$ | async pipe marks component as dirty

OnPush + Observables + Who is triggering zone.js?

If our data changes without our interaction (click, mouseover etc.) probably there is a setTimeout or setInterval or an HTTP call being made somewhere under the hood that triggers zone.js.

Hereā€™s how we can easily break it šŸ§Ø

@Component({
  selector: 'todos',
  standalone: true,
  imports: [AsyncPipe, JsonPipe],
  template: `{{ todos$ | async | json }}`,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TodosComponent {
  private http = inject(HttpClient);
  private ngZone = inject(NgZone);

  todos$ = of([] as any[]);

  ngOnInit() {
    this.ngZone.runOutsideAngular(() => {
      setTimeout(() => {
        // this will be updated, but there's nothing triggering zonejs
        this.todos$ = this.getTodos();
      });
    });
  }

  getTodos() {
    return this.http
      .get<any>('https://jsonplaceholder.typicode.com/todos/1')
      .pipe(shareReplay(1));
  }
}

What we have done here is:

  • In ngOnInit, we have used ngZone.runOutsideAngular() an API that allows us to run things outside the Angular zone.
  • We use setTimeout (to skip the first task being run and also because Angular runs change detection at least once by default) and inside the setTimeout, we assign a value to the observable (yay we have a change).
  • Because setTimeout wonā€™t run inside the zone, also the API call will be made outside the zone because the code is run inside runOutsideAngular, there is nothing notifying zonejs that something changed.
  • Run this code in your app and see that only ā€œ[]ā€ will be shown in the browser.
  • Broken State šŸ§Ø!

Not great šŸ˜„! But, one other thing that we start questioning is:

Why do we need to mark all the ancestors dirty?

The reason for this is simple, if we donā€™t mark all ancestors as dirty, we can get a broken state even faster. How?

Letā€™s see the above example again, but now, mark only the component and its children as dirty.

mfc-click.webp

So, we mark only the component that had the click and its children to be marked for check. The moment the tick happens it will get to the parent component which is OnPush, check that itā€™s not dirty, and skip it.

Broken state if we donā€™t mark ancestors as dirty when using markForCheck Broken state if we donā€™t mark ancestors as dirty when using markForCheck

Thatā€™s how we get to a broken state again šŸ§Ø!

Why canā€™t we just run the change detection for the component that is marked as dirty?

We can do that using the detectChanges method in the ChangeDetectorRef class. But it has its drawbacks. Because that method runs change detection synchronously, it can cause performance issues. Because everything will be done in the same browser task, it may block the main thread and cause jank. Imagine detecting changes for a list of 100 items every 1 or 2 seconds. Thatā€™s a lot of work for the browser.

markForCheck vs detectChanges (coalesced run vs sync runs)

When we use markForCheck we just tell Angular that a component is dirty, and nothing else happens, so even if we call markForCheck 1000 times itā€™s not going to be an issue. But, when we use detectChanges, Angular will do actual work like checking bindings and updating the view if needed. And thatā€™s why we should use markForCheck instead of detectChanges.

Canā€™t we schedule detectChanges in the next browser task?

We can, thatā€™s what push pipe or rxLet directive from rx-angular does. It schedules the change detection in the next browser task. But, itā€™s not a good idea to do that for every component. Because, if we have a list of 100 items, and we schedule the change detection for every item, we will have 100 browser tasks. And thatā€™s not good for performance either.

Signals šŸš¦

The frontend world is moving towards signals. Solid.js, Svelte, Vue, and Angular are creating their signal implementations. And thatā€™s because signals are a better way to manage state and state changes.

Signals in Angular have brought a lot of DX benefits. We can easily create and derive state and also run side effects when the state changes using effects. We donā€™t have to subscribe to them, we donā€™t have to unsubscribe from them, and we donā€™t have to worry about memory leaks šŸ§Æ.

We can just call them and they will return their current value.

const name = signal('John'); // create a signal with initial value
const upperCaseName = computed(() => name().toUpperCase()); // create a computed signal

effect(() => {
  console.log(name() + ' ' + upperCaseName()); // run side effect when name or upperCaseName changes
});

setTimeout(() => {
  name('Jane'); // change the name after 1 second
}, 1000);

// Output:
// John JOHN
// Jane JANE

We can also use signals in the template just like normal function calls.

@Component({
  template: `
    <button (click)="name.set('Jane')">Change name</button>
    <p>{{ name() }}</p>
  `
})
export class AppComponent {
  name = signal('John');
}

If you ask if calling a function in the template is a good idea, I would say that itā€™s a good idea if the function call is cheap, and calling a signal is cheap. Itā€™s just a function call that returns a value (without computing anything).

Iā€™ve also written about it here:

Signals and Change Detection

In v17 Angular change detection got an upgrade šŸš€!

Angular templates now understand signals as something more than function calls. Below is one of the PRs making this a reality.

feat(core): Mark components for check if they read a signal

Before we used the async pipe, so it would call the markForCheck method, and with signals, we just have to normally call them. Angular now will register an effect (consumer) that will listen to this signal and mark the template for check every time the signal changes.

signals-data-in-template.webp

The first benefit is that we donā€™t need async pipe anymore šŸŽ‰.

The second PR that improves change detection is this one:

fix(core): Ensure backwards-referenced transplanted views are refreshed

Which solved an issue not related to signals but to change detection itself (which I wonā€™t explain in detail).

By using the mechanism introduced by it, we got the 3rd PR which added Glo-cal change detection (Global + Local change detection) (a term coined by my friend @Matthieu Riegler)

perf(core): Update LView consumer to only mark component for check

So letā€™s better understand glo-cal (local) change detection šŸ‘‡

Local Change Detection (Targeted mode)

One of those PRs I linked above introduced two new flags in Angular.

Two new flags (RefreshView & HAS_CHILD_VIEWS_TO_REFRESH) Two new flags (RefreshView & HAS_CHILD_VIEWS_TO_REFRESH)

How do they work?

When the template effect runs, Angular will run a function called markViewForRefresh which sets the current component flag to RefreshView and then calls markAncestorsForTraversal which will mark all the ancestors with HAS_CHILD_VIEWS_TO_REFRESH.

/**
 * Adds the `RefreshView` flag from the lView and updates HAS_CHILD_VIEWS_TO_REFRESH flag of
 * parents.
 */
export function markViewForRefresh(lView: LView) {
  if (lView[FLAGS] & LViewFlags.RefreshView) {
    return;
  }
  lView[FLAGS] |= LViewFlags.RefreshView;
  if (viewAttachedToChangeDetector(lView)) {
    markAncestorsForTraversal(lView);
  }
}

And hereā€™s how it looks in the graph (updated tree structure to showcase more edge cases)šŸ‘‡

signal-cd-click.webp

So, the component that has signal changes is marked with the orange color border and the parents now have the ā¬ icon to tell that they have child views to refresh.

NOTE: We still depend on zone.js to trigger change detection.

The moment zone.js kicks in (the same reasons as before) it will call appRef.tick() and then we will have top-down change detection with some differences and new rules!

Targeted Mode Rules

NgZone triggers change detection in GlobalMode (it will go top-down checking & refreshing all components)

In GlobalMode we check CheckAlways (normal component without any change detection strategy set) and Dirty OnPush components

What triggers TargetedMode?

  • When in GlobalMode we encounter a Non-Dirty OnPush component, we switch to TargetedMode!

In TargetedMode:

  • Only refresh a view if it has the RefreshView flag set
  • DO NOT Refresh CheckAlways or regular Dirty flag views
  • If we reach a view with RefreshView flag, traverse children in GlobalMode

Letā€™s go one by one on components.

  1. Root component is a normal component (CheckAlways) so we just check & refresh bindings if needed and we continue to its children.

signal-cd-click-2.webp

  1. All CheckAlways components will continue to work the same as before.

All CheckAlways components are refreshed All CheckAlways components are refreshed

  1. OnPush will continue to work the same, so if itā€™s not marked as dirty it wonā€™t be checked.

  2. If we check the other component that is OnPush + HAS_CHILD_VIEWS_TO_REFRESH but not dirty we get our trigger for TargetedMode (check the rules above)

signal-cd-click-4.webp

  1. The component itself is not going to be refreshed, letā€™s go to the children

TargetedMode on CheckAlways component -> Skip TargetedMode on CheckAlways component -> Skip

  1. Then we reach a RefreshView component and we are on TargetedMode, which means we refresh the bindings. We also convert to GlobalMode to make sure CheckAlways children components also get refreshed correctly.

signal-cd-click-6.webp

  1. Now we are in GlobalMode and we have a CheckAlways component, so we just refresh normally

signal-cd-click-7.webp

Thatā€™s all about the new Targeted Change Detection.

If we look at the final tree, we can see that we skipped more components than before when we reach an OnPush component that is not dirty.

signal-cd-click-8.webp

Targeted Change Detection = OnPush without footguns šŸ”«


You can play with all these change detection rules in this app by Mathieu Riegler šŸ”Ø

Understand Angular Change Detection

Angular Change Detection Playground

Zoneless Angular ā€” Letā€™s remove zone.js from Angular

The moment we remove zone.js from Angular we are left with code that runs but doesnā€™t update anything in the view (The bootstrap time of zone.js and all the pressure it puts in the browser gets removed too! We also remove 15kb from the bundle size šŸ˜Ž). Because nothing is triggering appRef.tick().

But, Angular has some APIs that tell it that something changed. Which ones?

  • markForCheck (used by async pipe)
  • signal changes
  • event handlers that mark view dirty
  • setting inputs on components created dynamically with setInput()

Also, OnPush components already works with the idea that it needs to tell Angular that something changed.

So, instead of having zone.js schedule tick() we can have Angular schedule tick() when it knows something changed.

refactor(core): Add scheduler abstraction and notify when necessary

In this PR (experimental) we can see that markViewDirty now will notify the changeDetectionScheduler that something changed.

export function markViewDirty(lView: LView): LView|null {
  lView[ENVIRONMENT].changeDetectionScheduler?.notify();
  // ... code removed for brevity
}

The scheduler is supposed to schedule tick(), as we can see in this other experimental implementation of a zoneless scheduler.

Add zoneless scheduler to the ApplicationRef.isStable indicator

@Injectable({providedIn: 'root'})
class ChangeDetectionSchedulerImpl implements ChangeDetectionScheduler {
  private appRef = inject(ApplicationRef);
  private taskService = inject(PendingTasks);
  private pendingRenderTaskId: number|null = null;

  notify(): void {
    if (this.pendingRenderTaskId !== null) return;

    this.pendingRenderTaskId = this.taskService.add();
    setTimeout(() => {
      try {
        if (!this.appRef.destroyed) {
          this.appRef.tick();
        }
      } finally {
        const taskId = this.pendingRenderTaskId!;
        this.pendingRenderTaskId = null;
        this.taskService.remove(taskId);
      }
    });
  }
}

This is experimental, but we can see that basically, it will coalesce all notify() calls and run them only once (in this code is only once per macroTask ā€” setTimeout, but maybe we get it only once per microTask ā€” Promise.resolve())

What are we supposed to understand from this?

Apps that are currently using the OnPush change detection strategy will work fine in a zoneless angular world.

Zoneless Angular !== Glo-cal (local) change detection

Zoneless Angular is not the same as local change detection. Zoneless Angular is just removing zone.js from Angular and using the APIs that Angular already has to schedule tick().

Real Local change detection is a new feature that will allow us to run change detection for only a sub-tree of components (not the whole component tree) that currently use the OnPush change detection strategy.

Signals change detection (No OnPush, No Zone.js, Signals only)

One thing signal change detection will bring is native unidirectional data flow (two-way data binding without headaches).

Watch this video Rethinking Reactivity w/ Alex Rickabaugh | Angular Nation

While glo-cal change detection with OnPush and zoneless are great, with only signal components, we probably can do even better.

What if we didnā€™t have to use OnPush? Or run change detection for the whole component tree? What if we could just run change detection for the views inside components that have changed?

Read more in the RFC:

Sub-RFC 3: Signal-based Components

Credits where itā€™s due:

Thanks for reading!

If this article was interesting and useful to you, and you want to learn more about Angular, support me by buying me a coffee ā˜•ļø or follow me on X (formerly Twitter) @Enea_Jahollari where I tweet and blog a lot about Angular latest news, signals, videos, podcasts, updates, RFCs, pull requests and so much more. šŸ’Ž


Share this article:

Previous articles

Don't miss out on our previous articles.