Angular logo with the headline "OnPush becomes default! 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: ` 
     <button (click)="onClick()">{{ count }}</button> 
  `,
})
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.

Screen recording of an Angular component that increments a plain count property inside setTimeout on button click.

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: ` 
    <button (click)="onClick()">{{ count() }}</button> 
  `,
})
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.

Screen recording of an Angular component that increments a count signal with update() inside setTimeout on button click.

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: `
    <button (click)="loadUserData()">Load User Data</button>
    <div>{{ userData()?.name }}</div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class Component {
   private userService = inject(UserService);

-  userData?: UserData | undefined;
+  readonly userData = signal<UserData | undefined>(undefined);

loadUserData() {
    this.userService.getUserData().pipe(take(1)).subscribe((data) => {
-      this.userData = data;
+      this.userData.set(data);
    });
  }
}

Update signal state immutably

const userData = signal<UserData | undefined>(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: `
    <div>{{ userData()?.name }}</div>
    <button (click)="updateName()">Update Name</button>
  `,
})
class Component {
  private userData = signal<UserData | undefined>(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: `
    <div>{{ userData()?.name }}</div>
    <button (click)="updateName()">Update Name</button>
  `,
})
class Component {
  private userData = signal<UserData | undefined>(undefined);
  updateName() {
    setTimeout(() => {
      this.userData().name = "John Doe";
    }, 1000);
  }
}

Screen recording of an Angular component that mutates a signal object's name inside setTimeout, while the button still shows "john".

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<UserData | undefined>(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!