← All articles

Migrating to OnPush Change Detection in Ionic Angular

How I migrated winecode to OnPush using Angular Signals—excluding ion-nav and ion-router-outlet components—with step-by-step migration and ESLint checks.

Published
Migrating to OnPush Change Detection in Ionic Angular cover image

Ionic Angular used to warn that OnPush change detection could cause unexpected bugs and that you should avoid it. After re-reading the docs, they now spell out where OnPush is off limits, and everywhere else it looks fine to use.

Do not use OnPush change detection on components that use ion-nav or ion-router-outlet. Lifecycle hooks such as ngOnInit will not run. Asynchronous state changes may also fail to render correctly.
https://ionicframework.com/docs/angular/lifecycle#angular-life-cycle-events

So in winecode ( https://site.winecode.app/ ), my product, I migrated every component except those using ion-nav or ion-router-outlet to OnPush change detection. It runs without issues, and with fewer checks the app feels snappier, so here is how I did it. There is no automation—mostly manual work. 😄

Basic approach

The app already has a large codebase, so rewriting everything with ChangeDetectorRef.markForCheck and re-testing is not realistic. Instead, I use Signals, introduced in v16, for change detection. When a template using OnPush contains Signals, Angular runs change detection for you automatically. The main OnPush pitfall is “I changed something but the template did not update,” so I make every property readonly. Constants are fine as-is.

Steps

0. Preparation

For some APIs, ngxtension provides automated migration tools—update if you have not already.

npm i ngxtension --save-dev

New output() Migration

% ng g ngxtension:convert-outputs --project=app

https://ngxtension.netlify.app/utilities/migrations/new-outputs-migration/https://ngxtension.netlify.app/utilities/migrations/new-outputs-migration/

Queries Migration

% ng g ngxtension:convert-queries --project=app

https://ngxtension.netlify.app/utilities/migrations/queries-migration/https://ngxtension.netlify.app/utilities/migrations/queries-migration/

Signal Inputs Migration

% ng g ngxtension:convert-signal-inputs --project=app

https://ngxtension.netlify.app/utilities/migrations/signal-inputs-migration/https://ngxtension.netlify.app/utilities/migrations/signal-inputs-migration/

1. Pick components to migrate

Migrating every component at once is impossible, so I migrate one component at a time. Migrating from the top (callers) first can break unmigrated callees, so I start from the bottom (callees). A slightly nostalgic analogy: if you design components along Atomic Design,

atoms → molecules → organisms → templates → pages

migrating in that order works well.

2. Switch the target component to OnPush

With strong resolve, switch to OnPush.

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

3. Make properties on the target component readonly

Errors will show up first; with strong resolve, add readonly to everything.

  export class HomePage implements OnInit, OnDestroy, ViewDidEnter {
-   SlipType = SlipType;
-   version = packageInfo.version;
-   emailUser = '';
-   initSubscription$: Subscription[] = [];
+   readonly SlipType = SlipType;
+   readonly version = packageInfo.version;
+   readonly emailUser = '';
+   readonly initSubscription$: Subscription[] = [];

As an aside, you can also mark injected services as readonly (not that anyone would overwrite them), and I think making every property readonly by default is reasonable. Team coding rules no longer need to argue “why is this a variable here but a Signal there.”

4. Replace anything that errors with Signals

  export class HomePage implements OnInit, OnDestroy, ViewDidEnter {
    readonly SlipType = SlipType; // Keep as-is because it is a constant
-   readonly version = packageInfo.version;
-   readonly emailUser = '';
    readonly initSubscription$: Subscription[] = []; // Keep as-is because we only push to it
+   readonly version = signal<string>(packageInfo.version); // Make it a Signal because it is updated
+   readonly emailUser = model<string>(''); // Use a Signal model for two-way binding

You could move everything to Signals without much downside (asReadonly() exists on Signals), but here I only convert what is necessary.

5. Fix call sites

This is steady work. In HTML templates, update bindings. Common patterns need parentheses on bindings, @if, and @for.

-   @if (emailUser) {
-     <ion-text>{{ emailUser }}</ion-text>
-   }
  
+   @if (emailUser()) {
+     <ion-text>{{ emailUser() }}</ion-text>
+   }

Note that ngModel does not need rewriting, so be careful not to change [(ngModel)]="emailUser" to [(ngModel)]="emailUser()" by mistake.
For HTML templates, an ESLint extension checks whether you forgot to use a property as a Signal; if strictTemplates is enabled in tsconfig.json, I do not worry much about misses. That applies to the component class only.

TypeScript rewrites are obviously required too. Follow the Signal docs.

- if (this.emailUser) {
+ if (this.emailUser()) {
    console.log(this.emailUser);
  }

One deliberate gotcha: when updating a Signal holding an object, if the object reference stays the same, the Signal does not detect a change. On update, create and return a new object (below, { ...user } creates a new object).

- this.user.email = email;
+ this.user.update((user) => {
+   user.email = email;
+   return { ...user }
+ });

Checking for missed work

1. TypeScript rewrite mistakes

This is what I missed most often in my own work.

class HomePage {
  readonly isReady = signal<boolean>(false);
  ...
  hoge() {
    /**
     * This is incorrect. Because it is a Signal, it always returns true. The correct form is `if (this.isReady())`.
     */
    if (this.isReady) {   
      console.log('ready');
    }
  }
}

I cannot eyeball everything, so I wrote a very simple ESLint rule that checks whether properties declared with signal or model are used with () or .**.

https://github.com/rdlabo-dev/eslint-plugin-rules/blob/main/docs/rules/signal-use-as-signal.mdhttps://github.com/rdlabo-dev/eslint-plugin-rules/blob/main/docs/rules/signal-use-as-signal.md

Worth enabling during migration to prevent trouble…?

2. Which files are not done yet?

@angular-eslint/prefer-on-push-component-change-detection lets ESLint find components not using OnPush. It was handy to see how many files were left.

Summary

Depending on the app, the workload is substantial, so you cannot start lightly, but it is worth keeping in mind as one performance lever. And please do not switch components that use ion-nav or ion-router-outlet.

For behavior after moving to OnPush, this article is detailed:

https://qiita.com/masaks/items/61150907ce95b509fcaahttps://qiita.com/masaks/items/61150907ce95b509fcaa

See you next time.