Dec 17, 2025

Angular Change Detection + Signals: Real Debugging & Migration Guide

Tags

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. 


angular-change-detection-signals


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


Aspect
Zone.js + OnPush
Signals (Angular 17+)
Trigger
Async events (Zone.js)
Signal reads in templates
Granularity
Component tree
Individual bindings
Manual work
markForCheck() needed
Automatic
Performance
Good (subtree skipping)
Excellent (binding-level)
Mental model
Event-driven
Reactive primitives
Zoneless
Requires Zone.js
Native support


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


Before (Zone.js + OnPush):

@Component({ changeDetection: ChangeDetectionStrategy.OnPush })
export class DashboardComponent {
  @Input() users: User[] = [];
  
  constructor(private cdr: ChangeDetectorRef) {}
  
  updateUsers(users: User[]) {
    this.users = users;
    this.cdr.markForCheck();
  }
}


After (Signals):

@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)

Performance checklist:
  • OnPush on presentational components
  • trackBy on every *ngFor  
  • Pure pipes for filtering
  • Immutable updates or signals
  • async pipe (no manual subscribe)
  • Split god-components

Change detection mastery = predictable UI + blazing performance. Whether you choose OnPush optimization or signals revolution, understanding why and when transforms good Angular apps into great ones.


EmoticonEmoticon