<aside> ⚡

Goal: migrate an IDEA Angular/Ionic app from Zone.js change detection to signals + OnPush + zoneless (provideZonelessChangeDetection, no zone.js). All IDEA front-ends share the same stack — Angular 20/22, Ionic 8, @idea-ionic v8, standalone, no NgModules — so this playbook transfers across projects (as of June 2026).

</aside>

Why do it

Strategy (the order matters)

  1. Convert state / inputs / outputs to signals and set OnPush on every component — while Zone is still on.
  2. ng build green on Zone = a safe, reversible checkpoint.
  3. Flip zoneless dead last, then drop zone.js.

Small app (≤ ~50 files): one pass. Large app (100+ components): go module-by-module over several sessions and flip zoneless only once the whole tree is OnPush-clean and verified under Zone.

The mechanical recipe

@Input() x: T;                    →  x = input.required<T>();   // or input<T>() / input(default)
@Output() y = new EventEmitter(); →  y = output<...>();         // y.emit() unchanged
@ViewChild('r') r;                →  r = viewChild<T>('r');     // read this.r()
mutableState = v;                 →  state = signal(v);         // write .set() / .update()
get foo() { return ... }          →  foo = computed(() => ...);
@Input() v; @Output() vChange;    →  v = model<T>();            // two-way; v.set(x) auto-emits

Add changeDetection: ChangeDetectionStrategy.OnPush to every @Component. In templates {{ x }}{{ x() }}, [p]="x"[p]="x()", @if (x)@if (x()); in TS this.xthis.x().

<aside> 🚨

The golden rule: the moment you set OnPush, every mutable property read in that component's template must be a signal (or be pushed via a signal / markForCheck). Convert all of a component's view state in the same pass you flip it to OnPush, then verify. A plain property mutated inside an await/.then()/timer/non-template listener will silently stop updating.

</aside>

What schedules change detection under zoneless

Trigger Re-renders?
A signal read in the template changes (.set/.update to a new value)
A template event fires — (click), (ngModelChange), an output (x)
AsyncPipe receives a new value
ChangeDetectorRef.markForCheck()
Plain field mutated in await/.then()/setTimeout/setInterval/.subscribe ❌ stale
In-place mutation of a signal's object/array (obj().p = x, arr().push()) ❌ — set() with the same reference does not notify (Object.is)
matchMedia/resize/native addEventListener writing a plain field ❌ stale

Pitfalls & hard-won lessons