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
OnPushchange detection on components that useion-navorion-router-outlet. Lifecycle hooks such asngOnInitwill 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/
Queries Migration
% ng g ngxtension:convert-queries --project=app
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/
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.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/61150907ce95b509fcaa
See you next time.