Angular OnPush & Zoneless Migration
Purpose
Guide the migration from ChangeDetectionStrategy.Default to OnPush, and then to full zoneless change detection. This is the final phase of an Angular modernization — it requires signals migration to be substantially complete first.
Prerequisites
Before starting OnPush migration:
- •Most
@Input()should be migrated toinput()signals - •Most
@Output()should be migrated tooutput()signals - •Key state properties should be
signal()orcomputed()
CRITICAL: Subscribe Callback Audit (RULE 4)
This is the #1 source of OnPush bugs. Every .subscribe() callback that sets a component property will silently break with OnPush.
Pre-Migration Audit
# Find all subscribe callbacks that set properties
grep -rn "\.subscribe(" --include="*.ts" src/app/ projects/ -A 5 | \
grep "this\.[a-zA-Z].*="
The Problem
With Default change detection, zone.js triggers CD after every async operation. With OnPush, CD only runs when:
- •An input reference changes
- •A template event fires
- •A signal notifies
- •
markForCheck()is called explicitly - •The
asyncpipe triggers
Subscribe callbacks are NONE of these. Setting this.data = response inside .subscribe() won't update the template.
The Fix
For each subscribe callback that sets state:
// BEFORE (breaks with OnPush)
this.apiService.getData().subscribe(data => {
this.data = data; // Template won't update!
});
// FIX 1: Convert to signal (preferred)
data = signal<DataDTO | null>(null);
// ...
this.apiService.getData().subscribe(data => {
this.data.set(data); // Signal notifies CD
});
// FIX 2: Use markForCheck (escape hatch)
constructor(private cdr: ChangeDetectorRef) {}
// ...
this.apiService.getData().subscribe(data => {
this.data = data;
this.cdr.markForCheck();
});
// FIX 3: Use async pipe (cleanest for templates)
data$ = this.apiService.getData();
// Template: @if (data$ | async; as data) { ... }
MCP Tool Integration
Use the Angular CLI MCP tool for analysis:
mcp__angular-cli__onpush_zoneless_migration({ fileOrDirPath: '/path/to/component' })
This tool:
- •Analyzes a component/directory
- •Identifies the next action to take
- •Provides step-by-step instructions
- •Must be called repeatedly until no more actions needed
Limitation: The tool identifies issues but doesn't always fix them correctly for complex cases (subscribe callbacks, timer-based code, third-party library callbacks). Use the tool for analysis, apply fixes manually.
Three-Phase OnPush Approach
Phase 1: Trivial Components
Components with no reactive state — just add OnPush.
Criteria:
- •No
subscribe()calls - •No
setTimeout()/setInterval() - •No manual DOM manipulation
- •Template only uses inputs, outputs, and static content
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
// ...
})
Phase 2: Components with Outputs
Components that emit events — convert outputs to output() signals, then add OnPush.
// Before @Output() loginSuccess = new EventEmitter<void>(); // After loginSuccess = output<void>();
Phase 3: Complex Components
Components with subscriptions, timers, or third-party library callbacks.
For each one:
- •Run the subscribe audit (RULE 4)
- •Convert state properties to signals
- •Add
takeUntilDestroyed()for cleanup - •Add OnPush
- •Build and test
Object Mutation Fixes
OnPush requires immutable state updates. Common fixes:
Image Rotation
// BEFORE (mutation — OnPush won't detect)
this.activeImage.rotate = this.activeImage.rotate - 90;
// AFTER (new object — OnPush detects)
this.activeImage.set({
...this.activeImage(),
rotate: this.activeImage().rotate - 90
});
Array Updates
// BEFORE this.rows.push(newRow); // AFTER this.rows.set([...this.rows(), newRow]);
Nested Object Updates
// BEFORE
this.config.settings.theme = 'dark';
// AFTER
this.config.set({
...this.config(),
settings: { ...this.config().settings, theme: 'dark' }
});
Animation Callbacks with OnPush
Animation completion callbacks run outside Angular's CD. With OnPush, use signals:
animationState = signal<'open' | 'closed' | 'void'>('open');
onConfirm() {
this.animationState.set('closed'); // Signal triggers CD
}
onAnimationDone(event: AnimationEvent) {
if (event.toState === 'closed') {
this.close();
}
}
NgZone Compatibility Assessment
These NgZone patterns ARE compatible with zoneless:
- •
NgZone.run()— Forces synchronous CD, works in zoneless - •
NgZone.runOutsideAngular()— Still useful for performance
These NgZone patterns are NOT compatible:
- •
NgZone.onStable— No zone = never fires - •
NgZone.onMicrotaskEmpty— No zone = never fires - •
NgZone.isStable— Always true in zoneless
# Check for incompatible patterns grep -rn "onStable\|onMicrotaskEmpty\|isStable" --include="*.ts" src/ projects/
Enabling Zoneless Change Detection
After ALL components are OnPush-compatible:
Step 1: Add provider
// app.module.ts (or app.config.ts for standalone)
import { provideZonelessChangeDetection } from '@angular/core';
@NgModule({
providers: [
provideZonelessChangeDetection(),
],
})
Step 2: Remove zone.js from polyfills
// angular.json "polyfills": [ "@angular/localize/init" // Remove: "zone.js" // Remove: "zone-flags.ts" ]
Step 3: Verify bundle savings
Expected: ~92KB reduction in polyfills bundle.
yarn build:dev # Compare polyfills bundle size before and after
OnPush Audit Checklist
See references/onpush-audit-checklist.md for the complete per-component audit.
References
- •
references/onpush-audit-checklist.md— Per-component audit checklist - •
references/zone-boundary-patterns.md— Common zone boundary issues and fixes - •
references/softever-subscribe-to-signal.md— Real before/after examples from a 50-file subscribe→signal migration, including scope reduction strategy and common bugs