Use this skill when the user asks to "migrate to OnPush", "enable OnPush change detection", "zoneless Angular", "remove zone.js", "provideZonelessChangeDetection", or when working with change detection strategy. Covers the three-phase OnPush approach (trivial → outputs → complex), subscribe() callback auditing, object mutation fixes, and zoneless enablement. Encodes RULE 4 (subscribe callback audit).
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.
Before starting OnPush migration:
@Input() should be migrated to input() signals@Output() should be migrated to output() signalssignal() or computed()This is the #1 source of OnPush bugs. Every .subscribe() callback that sets a component property will silently break with OnPush.
# Find all subscribe callbacks that set properties
grep -rn "\.subscribe(" --include="*.ts" src/app/ projects/ -A 5 | \
grep "this\.[a-zA-Z].*="
With Default change detection, zone.js triggers CD after every async operation. With OnPush, CD only runs when:
markForCheck() is called explicitlyasync pipe triggersSubscribe callbacks are NONE of these. Setting this.data = response inside .subscribe() won't update the template.
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) { ... }
Use the Angular CLI MCP tool for analysis:
mcp__angular-cli__onpush_zoneless_migration({ fileOrDirPath: '/path/to/component' })
This tool:
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.
Components with no reactive state — just add OnPush.
Criteria:
subscribe() callssetTimeout() / setInterval()@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
// ...
})
Components that emit events — convert outputs to output() signals, then add OnPush.
// Before
@Output() loginSuccess = new EventEmitter<void>();
// After
loginSuccess = output<void>();
Components with subscriptions, timers, or third-party library callbacks.
For each one:
takeUntilDestroyed() for cleanupOnPush requires immutable state updates. Common fixes:
// 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
});
// BEFORE
this.rows.push(newRow);
// AFTER
this.rows.set([...this.rows(), newRow]);
// BEFORE
this.config.settings.theme = 'dark';
// AFTER
this.config.set({
...this.config(),
settings: { ...this.config().settings, theme: 'dark' }
});
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();
}
}
Official Angular v21 guidance: NgZone.run and NgZone.runOutsideAngular do NOT need to be removed for zoneless compatibility. Removing them can actually cause performance regressions for libraries that also support ZoneJS apps.
These NgZone patterns ARE compatible with zoneless (KEEP):
NgZone.run() — Becomes a no-op wrapper in zoneless; harmless to keepNgZone.runOutsideAngular() — Performance optimization still valid; documents intent even in zonelessThese NgZone patterns are NOT compatible (MUST REPLACE):
NgZone.onStable — No zone = never fires. Replace with afterNextRender() or afterRender()NgZone.onMicrotaskEmpty — No zone = never fires. Replace with afterNextRender()NgZone.isStable — Always true in zoneless. Remove or use PendingTasksCommon valid patterns to keep:
runOutsideAngular wrapping high-frequency DOM events (mouse, drag, scroll, keyboard)runOutsideAngular wrapping third-party library initialization (Highcharts, tippy.js, Pickr, calendars)zone.run() re-entering Angular from third-party callbacks (chart events, color picker save, tooltip show/hide)zone.run() after signal.set() — redundant but harmless (signal already triggers CD)# Check for incompatible patterns (these MUST be fixed)
grep -rn "onStable\|onMicrotaskEmpty\|isStable" --include="*.ts" src/ projects/
# Catalog all NgZone usages for review
grep -rn "NgZone\|\.zone\." --include="*.ts" src/ projects/ | grep -v node_modules | grep -v ".spec.ts"
See references/softever-ngzone-patterns.md for all 7 real NgZone usages in this codebase with analysis.
After ALL components are OnPush-compatible:
// app.module.ts (or app.config.ts for standalone)
import { provideZonelessChangeDetection } from '@angular/core';
@NgModule({
providers: [
provideZonelessChangeDetection(),
],
})
// angular.json
"polyfills": [
"@angular/localize/init"
// Remove: "zone.js"
// Remove: "zone-flags.ts"
]
Expected: ~92KB reduction in polyfills bundle.
yarn build:dev
# Compare polyfills bundle size before and after
See references/onpush-audit-checklist.md for the complete per-component audit.
references/onpush-audit-checklist.md — Per-component audit checklistreferences/zone-boundary-patterns.md — Common zone boundary issues and fixesreferences/softever-subscribe-to-signal.md — Real before/after examples from a 50-file subscribe→signal migration, including scope reduction strategy and common bugsreferences/softever-ngzone-patterns.md — All 7 real NgZone usages in the codebase with official Angular v21 compatibility analysis and migration decision matrix