<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>
CheckAlways). OnPush + signals skip clean subtrees → most of the runtime perf win.zone.js entirely and aligns with Angular's direction (zoneless is the default from v21+).OnPush work identically under Zone, so you can land them first and flip zoneless later.OnPush on every component — while Zone is still on.ng build green on Zone = a safe, reversible checkpoint.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.
@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.x → this.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>
| 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 |
ng build is the real safety net — not tsc. tsc --noEmit does not type-check templates. ng build (strict templates) flags every missed () deref (.X doesn't exist on Signal<T>). The one gap: a bare {{ signal }} interpolation renders the function and does not error — grep templates for these.[(ngModel)]="obj().prop" compiles and behaves (the (ngModelChange) event schedules CD). For a plain writable signal use split bindings: [ngModel]="s()" (ngModelChange)="s.set($event)".ngOnChanges still fires for signal input()s (changes.<input>.currentValue is populated). Prefer it for a faithful side-effect keyed on a specific input over an effect() that would also track an unrelated async signal — an effect folding in an async service signal can re-fire unexpectedly (e.g. when the organization/user loads).