OnPush will become the default CD Strategy - What to expect

The Angular team published an RFC to make OnPush the default CD (change detection) Strategy: https://github.com/angular/angular/discussions/66779. This change will be available in Angular v22.

Angular has always been a "magic-based" framework for a long time when it comes to change detection. That's because of zone.js handling most of the async operations and notifying Angular that something has changed.

@Component({
  template: ` 
      
  `,
})
export class AsyncClick {
  count = 0;
  onClick() {
    setTimeout(() => {
      this.count++;
    }, 1000);
  }
}

The last version of Angular (v21) removed that magic for new projects by making Zoneless the default choice. This means that the code above would not work as expected.

vid1

The reason for this is because nothing is listening to that setTimeout callback being executed. While the view still updates, it's not correct.

The reason it still updates is because when we click a button Angular triggers a change detection run (same as calling markForCheck manually). But it's one update cycle too late.

This means that we shouldn't depend anymore on zone.js notifying Angular that something has changed. We need to be explicit when we change state that shows something in the browser.

That's what signals solve! By using signals, we can tell Angular what exactly changed and the framework will take care of the rest.

@Component({
  template: ` 
     
  `,
})
export class AsyncClick {
  count = signal(0);  onClick() {
    setTimeout(() => {
      this.count.update((x) => x + 1);
    }, 1000);
  }
}

This now will work as expected. We can see the count updating in the browser as soon as the setTimeout callback is executed.

vid2

What about OnPush change detection strategy? Why does it matter when we have signals?

OnPush is what really makes Angular fast. Because it's the only way to skip change detection for a component and its children if they are not dirty (don't have changes). Signals just tell Angular what changed, while OnPush tells Angular what to skip (if not changed explicitly -> don't check).

When we update a signal that is used in the template, Angular will mark the component as dirty (RefreshView) and all its ancestors as well (using another flag HasChildViewsToRefresh). At the same time, a change detection run will be scheduled to run (checking bindings and refreshing the views if needed). Check more about this in this video: https://www.youtube.com/watch?v=FvNXnBdIX1M

Because Angular until now has used a ChangeDetectionStrategy.Default strategy, it was not possible to skip change detection for a component and its children if they are not dirty.

That's one of the reason why ChangeDetectionStrategy.Default will be renamed to ChangeDetectionStrategy.Eager (meaning that change detection will be run eagerly)

How to properly migrate to OnPush?

Set the change detection strategy to OnPush in the component

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

Convert state to use signals

@Component({
  template: `
    
    
{{ userData()?.name }}
`, changeDetection: ChangeDetectionStrategy.OnPush, }) export class Component { private userService = inject(UserService); - userData?: UserData | undefined; + readonly userData = signal(undefined); loadUserData() { this.userService.getUserData().pipe(take(1)).subscribe((data) => { - this.userData = data; + this.userData.set(data); }); } }

Update signal state immutably

const userData = signal(undefined);

// ❌ This will not work as expected
userData().name = "John Doe";

// ✅ This is the correct way to update the signal state immutably
userData.update((x) => ({ ...x, name: "John Doe" }));

A little bit of explanation about why direct mutation currently works:

@Component({
  template: `
    
{{ userData()?.name }}
`, }) class Component { private userData = signal(undefined); updateName() { this.userData().name = "John Doe"; } }

The reason why this works is because event listeners still mark the component as dirty. And because the component is dirty and the state is mutated, Angular will run CD and update the view. So, even though it is working, it's not the correct way to do it. Because same as before, if we put it inside a setTimeout, the view won't update as expected.

@Component({
  template: `
    
{{ userData()?.name }}
`, }) class Component { private userData = signal(undefined); updateName() { setTimeout(() => { this.userData().name = "John Doe"; }, 1000); } }

vid3

Recommandation: mark your signals readonly

By marking the signals as readonly, you will get a type error if you try to mutate the signal state directly.

const userData = signal(undefined);

// ❌ This will throw a type error
userData = { name: "John Doe";}

// ✅ This is the correct way to update the signal state immutably
userData.update((x) => ({ ...x, name: "John Doe" }));

I'm also working to get these as linting errors here: https://github.com/angular-eslint/angular-eslint/pull/2890

Angular v22 - Auto Migrations

When you will run ng update to update the Angular version to v22, Angular will automatically migrate your project.

  1. It will add ChangeDetectionStrategy.Eager to components without explicit change detection

    @Component({
      template: ''
    + changeDetection: ChangeDetectionStrategy.Eager
    })
    export class Cmp {}

  2. It will rename ChangeDetectionStrategy.Default to ChangeDetectionStrategy.Eager

    @Component({
      template: ''
    - changeDetection: ChangeDetectionStrategy.Default
    + changeDetection: ChangeDetectionStrategy.Eager
    })
    export class Cmp {}

  3. It will keep ChangeDetectionStrategy.OnPush unchanged

    @Component({
      template: ''
      // no changes here
      changeDetection: ChangeDetectionStrategy.OnPush
    })
    export class Cmp {}

After you migrate to v22, you can safely remove the line that sets OnPush change detection, as that would be the default.

Angular keeps getting better!