I remember debugging a production dashboard where the UI refused to update despite data flowing perfectly. Console showed data changes, but the screen stayed frozen. After hours, I realized it was an OnPush + mutable object trap.
This final article shares those "aha!" debugging moments, signals vs traditional change detection, and my migration roadmap from Zone.js to the future.
The Classic "UI Won't Update" Bug (And Fix)
Scenario: OnPush component with array @Input, mutating object inside:
// ParentComponent (Default)
users = [{ id: 1, name: 'Alice' }];
// ChildComponent (OnPush) FREEZES!
@Input() users: User[] = [];
ngOnChanges() {
if (this.users.length) {
this.users[0].name = 'Bob'; // Same array reference!
}
}
Problem: OnPush only checks when @Input reference changes. Mutating users[0] keeps the array reference identical → no check!
Fix 1 (Immutable):
this.users = [{ ...this.users[0], name: 'Bob' }];
Fix 2 (Signals - Angular 17+):
readonly users = signal<User[]>([{ id: 1, name: 'Alice' }]);
users.update(u => [{ ...u[0], name: 'Bob' }]); // Auto-triggers!
OnPush + ngFor Performance Disaster (Before/After)
The Crime (5000 users, 3.2s render):
<div *ngFor="let user of users">{{ user.name | expensiveFilter }}</div>
The Fix (180ms render):
@Component({ changeDetection: ChangeDetectionStrategy.OnPush })
export class UserListComponent {
@Input() users: User[] = [];
trackByUserId(index: number, user: User): number {
return user.id;
}
filterUsers = (users: User[]) =>
users.filter(u => u.active && u.role !== 'inactive');
}
<div *ngFor="let user of users; trackBy: trackByUserId">
{{ user.name | asyncFilter: user.active }}
</div>
Result: 94% faster rendering, smooth scrolling even on mobile.
Signals vs Traditional Change Detection: The Showdown
Migration Path:
Step 1: OnPush everywhere → 70% perf gain
Step 2: Signals for local state → 90% perf gain
Step 3: Zoneless Angular 18+ → 100% perf gain
Real-World Debugging Scenarios (My War Stories)
Scenario 1: Timer in OnPush Component
setInterval doesn't trigger OnPush
setInterval(() => this.time = new Date(), 1000);
Fix with markForCheck()
setInterval(() => {
this.time = new Date();
this.cdr.markForCheck();
}, 1000);
Scenario 2: Third-Party Library Updates
Chart.js updates don't trigger Angular CD
chart.update(data);
Manual trigger
chart.update(data);
this.cdr.detectChanges();
Scenario 3: Deeply Nested Mutable State
App → Dashboard → Widget → Chart (all OnPush)
Widget data mutation doesn't propagate up!
Immutable updates all the way
chartData = { ...chartData, value: newValue };
widgetData = { ...widgetData, chart: chartData };
dashboardData = { ...dashboardData, widgets: [widgetData] };
When to Migrate to Signals (Decision Matrix)
Migrate to Signals if:
- New greenfield app
- Performance-critical UI (dashboards, lists)
- Already using OnPush extensively
- Team comfortable with reactive primitives
Stick with OnPush + Zone.js if:
- Legacy app migration
- Heavy RxJS investment
- Simple CRUD forms
- Rapid prototyping
Complete Migration Example: Dashboard Component
@Component({ changeDetection: ChangeDetectionStrategy.OnPush })
export class DashboardComponent {
@Input() users: User[] = [];
constructor(private cdr: ChangeDetectorRef) {}
updateUsers(users: User[]) {
this.users = users;
this.cdr.markForCheck();
}
}
@Component({ changeDetection: ChangeDetectionStrategy.OnPush })
export class DashboardComponent {
readonly users = input.required<User[]>();
readonly activeCount = computed(() =>
this.users().filter(u => u.active).length
);
// No cdr.markForCheck() needed!
}
Final Recommendations: My Production Stack (2025)
- Small apps (<20 components): Default + basic RxJS
- Medium apps: OnPush + trackBy + async pipe
- Large apps: OnPush + Signals + zoneless (Angular 18+)
- Dashboards: Signals + OnPush + immutable data
- Forms: Default or Signals (no Zone.js needed)
- OnPush on presentational components
- trackBy on every *ngFor
- Pure pipes for filtering
- Immutable updates or signals
- async pipe (no manual subscribe)
- Split god-components
