+1 (415) 843-4662

Signals and zoneless change detection in Ionic Angular apps

Every Ionic Angular app we inherit still boots with zone.js. That was the only option for years, and it is why so many Ionic lists feel sluggish on mid-range Android: every touch event, every setTimeout, every XHR completion triggers a whole-application change detection pass, and a *ngFor of 500 ion-items gets re-checked each time.

Modern Angular gives you a way out. Signals make state reactive at the value level, OnPush plus signals removes most of the guesswork, and zoneless change detection drops zone.js entirely — smaller bundle, fewer wasted render passes, and stack traces that are actually readable. Ionic's Angular components are standalone and signal-friendly, so you can do this incrementally on a real app.

This tutorial migrates a typical Ionic Angular page to signals, then flips the app to zoneless and fixes the things that break.

0. Where you need to be first

  • Standalone components and bootstrapApplication (no NgModule app root). If you are still on IonicModule.forRoot(), do that migration first — see Upgrading Ionic 4/5 Angular apps.
  • Angular's own schematics do most of the mechanical work:
ng generate @angular/core:standalone      # run all three passes, commit each
ng generate @angular/core:control-flow    # *ngIf/*ngFor -> @if/@for
ng generate @angular/core:signals         # @Input/@Output -> input()/output()

Commit after each schematic and run the app. These are safe, reviewable diffs; the interesting work starts after them.

1. Signals instead of a BehaviorSubject soup

The pattern we find in most Ionic services: a BehaviorSubject per field, a handful of combineLatest, and templates full of | async. Signals collapse it.

Before:

@Injectable({ providedIn: 'root' })
export class OrdersService {
  private orders$ = new BehaviorSubject<Order[]>([]);
  private filter$ = new BehaviorSubject<'all' | 'open'>('all');
  private loading$ = new BehaviorSubject(false);

  readonly visible$ = combineLatest([this.orders$, this.filter$]).pipe(
    map(([orders, filter]) => filter === 'all' ? orders : orders.filter(o => o.open)),
  );
  // ...
}

After:

import { Injectable, computed, signal } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class OrdersService {
  private readonly orders = signal<Order[]>([]);
  readonly filter = signal<'all' | 'open'>('all');
  readonly loading = signal(false);

  readonly visible = computed(() => {
    const all = this.orders();
    return this.filter() === 'all' ? all : all.filter(o => o.open);
  });

  async load() {
    this.loading.set(true);
    try {
      this.orders.set(await this.api.listOrders());
    } finally {
      this.loading.set(false);
    }
  }

  markShipped(id: string) {
    this.orders.update(list =>
      list.map(o => (o.id === id ? { ...o, open: false } : o)));
  }
}

Two rules that keep this clean in practice:

  • Signals are values, not streams. Keep RxJS where you genuinely have event streams over time — websockets, debounced search input, retry pipelines — and convert at the boundary with toSignal() / toObservable() from @angular/core/rxjs-interop.
  • update(), not mutate. this.orders().push(x) will not notify anything. Always produce a new array or object.

Search input is the classic boundary case:

import { toSignal } from '@angular/core/rxjs-interop';

readonly query = signal('');
readonly results = toSignal(
  toObservable(this.query).pipe(
    debounceTime(250),
    distinctUntilChanged(),
    switchMap(q => (q.length < 2 ? of([]) : this.api.search(q))),
  ),
  { initialValue: [] as Order[] },
);

2. The page component

import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import {
  IonHeader, IonToolbar, IonTitle, IonContent, IonList, IonItem, IonLabel,
  IonSegment, IonSegmentButton, IonRefresher, IonRefresherContent, IonSpinner,
} from '@ionic/angular/standalone';
import { OrdersService } from './orders.service';

@Component({
  selector: 'app-orders',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [
    IonHeader, IonToolbar, IonTitle, IonContent, IonList, IonItem, IonLabel,
    IonSegment, IonSegmentButton, IonRefresher, IonRefresherContent, IonSpinner,
  ],
  template: `
    <ion-header><ion-toolbar><ion-title>Orders</ion-title></ion-toolbar></ion-header>
    <ion-content>
      <ion-refresher slot="fixed" (ionRefresh)="refresh($event)">
        <ion-refresher-content></ion-refresher-content>
      </ion-refresher>

      <ion-segment [value]="orders.filter()"
                   (ionChange)="orders.filter.set($any($event.detail.value))">
        <ion-segment-button value="all">All</ion-segment-button>
        <ion-segment-button value="open">Open ({{ openCount() }})</ion-segment-button>
      </ion-segment>

      @if (orders.loading()) {
        <ion-spinner class="ion-margin"></ion-spinner>
      } @else {
        <ion-list>
          @for (order of orders.visible(); track order.id) {
            <ion-item button (click)="open(order)">
              <ion-label>
                <h2>{{ order.reference }}</h2>
                <p>{{ order.customer }}</p>
              </ion-label>
            </ion-item>
          } @empty {
            <ion-item lines="none"><ion-label>Nothing here yet.</ion-label></ion-item>
          }
        </ion-list>
      }
    </ion-content>
  `,
})
export class OrdersPage {
  readonly orders = inject(OrdersService);
  readonly openCount = computed(() => this.orders.visible().filter(o => o.open).length);

  async refresh(ev: CustomEvent) {
    await this.orders.load();
    (ev.target as HTMLIonRefresherElement).complete();
  }
}

Notes that matter on device:

  • track order.id in @for is not optional. Without a stable key Angular destroys and rebuilds ion-items on every change, which is exactly the jank you were trying to remove.
  • Import individual Ionic standalone components, not IonicModule. That is what lets the bundler drop the components this page does not use — see performance tuning for the measurement side.
  • Signals read in the template are tracked automatically, so OnPush is safe without a single markForCheck().

3. Going zoneless

Zoneless is the payoff: no zone.js patching of every async API, and change detection that runs only when a signal actually changes.

// main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { provideZonelessChangeDetection } from '@angular/core';
import { provideIonicAngular } from '@ionic/angular/standalone';
import { provideRouter } from '@angular/router';
import { AppComponent } from './app/app.component';
import { routes } from './app/routes';

bootstrapApplication(AppComponent, {
  providers: [
    provideZonelessChangeDetection(),
    provideIonicAngular({ mode: 'ios' }),
    provideRouter(routes),
  ],
});

Then remove zone.js from the build so it stops shipping:

// angular.json -> architect.build.options
"polyfills": []           // was ["zone.js"]
// tsconfig.app.json — drop "zone.js" from "types" if present

Rebuild and check the bundle actually shrank (npx source-map-explorer www/main*.js); on the apps we have migrated it is roughly 30–35 KB gzipped off the initial chunk, and cold start improves because there is no polyfill patching on boot.

4. What breaks, and the fix

Zoneless does not break Ionic components — they are web components driving their own rendering — but it does break code that silently relied on zone.js to trigger a render.

SymptomCauseFix
UI does not update after a setTimeout, addEventListener, or a promise from a Capacitor pluginThe value lives in a plain class field, not a signalMake it a signal() and set() it
Value updates but only when you tap the screenSame as above, incidental CD from another eventSame fix
ChangeDetectorRef.detectChanges() sprinkled in old codeWritten for zone-era workaroundsDelete it; convert the state to signals
Third-party library never rendersMutates its own state outside AngularWrap its callbacks so they set() a signal, or keep that one component in a zoneful island temporarily
Tests hang on fixture.whenStable()fakeAsync/tick assumptionsUse await fixture.whenStable() with the zoneless test provider

Capacitor listeners are the most common offender in Ionic apps. Convert them at the source:

import { Injectable, signal } from '@angular/core';
import { Network } from '@capacitor/network';
import { App } from '@capacitor/app';

@Injectable({ providedIn: 'root' })
export class DeviceStateService {
  readonly online = signal(true);
  readonly active = signal(true);

  constructor() {
    Network.getStatus().then(s => this.online.set(s.connected));
    Network.addListener('networkStatusChange', s => this.online.set(s.connected));
    App.addListener('appStateChange', s => this.active.set(s.isActive));
  }
}

Because signal.set() schedules change detection itself, there is no NgZone.run() and no runOutsideAngular() anywhere in that service. That is the whole point.

For tests:

TestBed.configureTestingModule({
  providers: [provideZonelessChangeDetection()],
});
// ...
component.orders.filter.set('open');
await fixture.whenStable();
expect(screen.getAllByRole('listitem')).toHaveLength(3);

5. A safe rollout order

  1. Standalone + control-flow + signal-input schematics. Ship it.
  2. Convert services to signals one domain at a time, starting with the heaviest list screen. Ship each one.
  3. Switch every converted component to ChangeDetectionStrategy.OnPush. Anything that breaks here is state that is not yet a signal — fix it now, while zone.js is still there to soften the failure.
  4. Flip provideZonelessChangeDetection() behind a build flag, run your device test pass (@for lists, refreshers, modals, deep links, push handlers, background resume), then remove zone.js from the build.
  5. Keep the flag for one release so you can revert without a hotfix.

Steps 1–3 are worth doing even if you never go zoneless: most of the frame drops on long Ionic lists come from whole-app change detection, and OnPush plus signals removes them.

Need this done on a live app?

We do this migration as a scoped engagement — audit, signal conversion of the hot screens, zoneless flip, device regression pass — on apps already in the stores, without pausing feature work. If you have an Ionic Angular app that feels slow on Android and nobody wants to touch the change detection, talk to us or read more about our Ionic consulting engagements.