12 Change Detection: Synchronisation von State und View

12.1 Das fundamentale Problem

Moderne Web-Anwendungen sind interaktiv. User-Input ändert Application-State. HTTP-Responses aktualisieren Daten. Timer triggern Animationen. Jede dieser State-Änderungen muss in der View reflektiert werden. Die Frage ist: Wann und wie synchronisiert man State mit View?

Die naive Lösung ist polling – kontinuierlich checken ob sich etwas geändert hat. Dies ist ineffizient und verschwendet CPU-Cycles. Eine bessere Lösung ist event-driven – detektiere wann Änderungen passieren können und checke dann. Angular’s Change Detection implementiert diese Strategie elegant.

Change Detection ist der Mechanismus der bestimmt wann Component-Templates re-evaluated werden müssen. Es ist ein Tree-Walking-Algorithm der von Root zu Leaves traversiert und Bindings checked. Die Performance dieses Mechanismus ist kritisch – ineffiziente Change Detection fühlt sich als UI-Lag an.

12.2 Zone.js: Automatische Trigger Detection

Angular nutzt Zone.js um zu detektieren wann asynchrone Operations completen. Zone.js ist ein Execution-Context-Tracker für asynchrone Tasks. Es patcht Browser-APIs wie setTimeout, Promise.then, addEventListener und wrappet sie in Zones.

Eine Zone ist ein Execution-Context der asynchrone Operations überlebt. Wenn ein async Task startet, noted Zone.js dies. Wenn er completes, notifiziert Zone.js Angular. Angular triggert dann Change Detection.

Dies ist fundamental: Ohne Zone.js müsste man manuell Change Detection triggern nach jedem async Event. Zone.js automatisiert dies. Der Preis ist overhead – Zone.js muss alle async APIs patchen.

// Was Zone.js macht (vereinfacht)
const originalSetTimeout = window.setTimeout;

window.setTimeout = function(callback: Function, delay: number) {
  return originalSetTimeout(() => {
    // Callback ausführen
    callback();
    
    // Angular notifizieren
    angular.triggerChangeDetection();
  }, delay);
};

Diese Patches sind transparent. Developer-Code kann normale Browser-APIs nutzen. Zone.js fängt alles ab und triggert Change Detection automatisch.

12.3 Der Change Detection Tree

Angular-Anwendungen sind Component-Trees. Change Detection traversiert diesen Tree top-down, von Root zu Leaves. Dies garantiert Konsistenz – Parent-State ist immer before Child-State aktualisiert.

Der Tree-Walk ist unidirektional. Data flows down via @Input, Events flow up via @Output. Change Detection folgt dem Data-Flow. Parents werden vor Children checked. Dies verhindert inconsistent intermediate States.

@Component({
  selector: 'app-root',
  template: `
    <app-header [user]="currentUser"></app-header>
    <app-content [data]="contentData"></app-content>
  `
})
export class AppComponent {
  currentUser = { name: 'John' };
  contentData = [...];
}

@Component({
  selector: 'app-header',
  template: `<nav>Welcome {{ user.name }}</nav>`
})
export class HeaderComponent {
  @Input() user: User;
}

Wenn currentUser in AppComponent ändert, läuft Change Detection: 1. Check AppComponent bindings 2. Check HeaderComponent bindings (empfängt neues user Input) 3. Check ContentComponent bindings

Die Reihenfolge ist garantiert. HeaderComponent sieht nie einen inconsistent State wo AppComponent updated ist aber Input noch alt.

12.4 Default Strategy: Check Alles

Die Default-Strategy checked alle Bindings bei jedem Change Detection Cycle. Jedes {{ expression }} im Template wird evaluated, jedes [property]="value" wird re-computed.

@Component({
  selector: 'app-counter',
  template: `
    <div>Count: {{ count }}</div>
    <div>Squared: {{ count * count }}</div>
    <button (click)="increment()">Increment</button>
  `
})
export class CounterComponent {
  count = 0;
  
  increment() {
    this.count++;
  }
}

Bei jedem Change Detection Cycle evaluiert Angular: - {{ count }} - {{ count * count }}

Dies passiert auch wenn count sich nicht geändert hat. Ein Click irgendwo in der App triggert Change Detection für alle Components. Dies ist ineffizient aber simpel und fehlertolerant.

Die Default-Strategy macht keine Annahmen über Component-State-Management. Sie ist safe – keine Änderung wird verpasst. Der Preis ist Performance. Bei hunderten Components und komplexen Expressions wird dies merkbar.

12.5 OnPush Strategy: Optimistic Updates

Die OnPush-Strategy ist eine Optimierung. Sie assumiert dass Component-State nur ändert wenn: 1. Ein @Input eine neue Referenz erhält 2. Ein Event-Handler innerhalb der Component läuft 3. AsyncPipe emittiert einen neuen Wert 4. Change Detection manuell getriggert wird

Diese Annahmen erlauben Angular, Components zu skippen deren State nicht geändert haben kann. Das Result ist drastisch weniger Checks.

@Component({
  selector: 'app-product',
  template: `
    <h2>{{ product.name }}</h2>
    <p>{{ product.price | currency }}</p>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class ProductComponent {
  @Input() product: Product;
}

Diese Component wird nur gecheckt wenn das product Input eine neue Object-Referenz bekommt. Änderungen an Properties des existierenden Objects triggern keine Change Detection:

// In Parent-Component

// Dies triggert KEINE Change Detection für ProductComponent
this.product.price = 99.99;

// Dies triggert Change Detection
this.product = { ...this.product, price: 99.99 };

OnPush erfordert Immutability. State-Updates müssen neue Objects/Arrays kreieren statt existierende zu mutieren. Dies ist ein Pattern-Shift aber ermöglicht massive Performance-Gains.

Der Immutability-Constraint ist nicht nur für Performance. Er macht Code predictable. Wenn eine Function ein Object empfängt, kann es nicht mutiert werden. Dies vereinfacht Reasoning über Code-Flow.

12.6 Input-Changes und Referenz-Equality

OnPush checked Inputs via Referenz-Equality (===). Primitive Types (Number, String, Boolean) sind by-value. Objects und Arrays sind by-reference.

@Component({
  selector: 'app-list',
  template: `
    <div *ngFor="let item of items">{{ item }}</div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class ListComponent {
  @Input() items: string[];
}

// Parent
this.items = ['a', 'b', 'c'];

// Dies triggert KEINE CD - gleiche Referenz
this.items.push('d');

// Dies triggert CD - neue Referenz
this.items = [...this.items, 'd'];

Die Array-Mutation ändert die Referenz nicht. OnPush merkt dies nicht. Die immutable Variante kreiert ein neues Array. OnPush detektiert die neue Referenz und triggert Change Detection.

Für Objects ist das Pattern identisch:

// Mutation - triggert KEINE CD
this.user.name = 'Jane';

// Immutable - triggert CD
this.user = { ...this.user, name: 'Jane' };

Der Spread-Operator ... ist essentiell für immutable Updates. Er kreiert ein shallow Copy. Für nested Objects ist dies insufficient:

interface User {
  name: string;
  address: {
    city: string;
  };
}

// Shallow copy - address ist noch die gleiche Referenz
this.user = { ...this.user };
this.user.address.city = 'Berlin'; // Mutation!

// Deep immutable update
this.user = {
  ...this.user,
  address: {
    ...this.user.address,
    city: 'Berlin'
  }
};

Dies ist verbose. Libraries wie Immer vereinfachen immutable Updates für nested Structures.

12.7 Events und Change Detection

Event-Handlers triggern Change Detection automatisch, auch mit OnPush:

@Component({
  selector: 'app-button',
  template: `
    <button (click)="handleClick()">{{ label }}</button>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class ButtonComponent {
  label = 'Click me';
  
  handleClick() {
    this.label = 'Clicked!';
    // Change Detection wird automatisch getriggert
  }
}

Der Click-Handler läuft innerhalb Angular’s Zone. Zone.js detektiert dies und triggert Change Detection. Die Component wird gechecked, label hat einen neuen Wert, das Template wird updated.

Dies gilt für alle DOM-Events: click, input, keydown, etc. Custom Events via @Output propagieren ebenfalls:

@Component({
  selector: 'app-child',
  template: `<button (click)="notify()">Notify Parent</button>`,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class ChildComponent {
  @Output() notification = new EventEmitter<string>();
  
  notify() {
    this.notification.emit('Hello from child');
  }
}

@Component({
  selector: 'app-parent',
  template: `
    <app-child (notification)="handleNotification($event)"></app-child>
    <div>{{ message }}</div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class ParentComponent {
  message = '';
  
  handleNotification(msg: string) {
    this.message = msg;
    // Change Detection läuft automatisch
  }
}

Der Event bubbles up. Der Parent-Handler läuft. Change Detection triggered. Beide Components werden gechecked.

12.8 AsyncPipe: Die intelligente Pipe

Die AsyncPipe subscribed Observables und triggert Change Detection bei jedem emittierten Wert. Sie ist perfekt für OnPush:

@Component({
  selector: 'app-users',
  template: `
    @if (users$ | async; as users) {
      <div *ngFor="let user of users">{{ user.name }}</div>
    }
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class UsersComponent {
  users$ = this.userService.getUsers();
  
  constructor(private userService: UserService) {}
}

Die Pipe: 1. Subscribed users$ beim Template-Render 2. Markiert die Component für Check bei jedem emittierten Wert 3. Unsubscribes automatisch bei Component-Destroy

Ohne AsyncPipe wäre dies verbose:

export class UsersComponent implements OnInit, OnDestroy {
  users: User[] = [];
  private subscription: Subscription;
  
  constructor(
    private userService: UserService,
    private cdr: ChangeDetectorRef
  ) {}
  
  ngOnInit() {
    this.subscription = this.userService.getUsers().subscribe(users => {
      this.users = users;
      this.cdr.markForCheck(); // Manuelles Triggern
    });
  }
  
  ngOnDestroy() {
    this.subscription.unsubscribe();
  }
}

AsyncPipe eliminiert diesen Boilerplate und integriert nahtlos mit OnPush.

12.9 Manuelle Change Detection Control

Manchmal ist automatische Change Detection nicht gewünscht. ChangeDetectorRef bietet fine-grained Control:

@Component({
  selector: 'app-manual',
  template: `<div>{{ data }}</div>`,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class ManualComponent {
  data = '';
  
  constructor(private cdr: ChangeDetectorRef) {}
  
  updateData(newData: string) {
    this.data = newData;
    
    // Option 1: Markiere für nächsten Cycle
    this.cdr.markForCheck();
    
    // Option 2: Check sofort (nur dieser Subtree)
    // this.cdr.detectChanges();
  }
}

markForCheck() markiert die Component und alle Parents bis Root als “dirty”. Der nächste Change Detection Cycle checked sie. detectChanges() triggert sofort einen Check für diese Component und Children.

Der Unterschied ist timing. markForCheck() wartet auf den nächsten regulären Cycle. detectChanges() läuft sofort, unabhängig von Zone.js. Dies ist useful für async Operations outside Angular’s Zone.

12.9.1 detach und reattach

Eine Component kann sich von Change Detection detachen:

constructor(private cdr: ChangeDetectorRef) {
  this.cdr.detach();
}

Detached Components werden nie automatisch gechecked. Change Detection muss manuell getriggert werden. Dies ist useful für high-frequency Updates:

export class AnimationComponent implements OnInit, OnDestroy {
  position = 0;
  private animationId: number;
  
  constructor(private cdr: ChangeDetectorRef) {
    this.cdr.detach(); // Detach from automatic CD
  }
  
  ngOnInit() {
    this.animate();
  }
  
  animate() {
    this.animationId = requestAnimationFrame(() => {
      this.position += 1;
      
      // Manual CD alle 100ms statt bei jedem Frame
      if (this.position % 100 === 0) {
        this.cdr.detectChanges();
      }
      
      this.animate();
    });
  }
  
  ngOnDestroy() {
    cancelAnimationFrame(this.animationId);
    this.cdr.reattach(); // Cleanup
  }
}

Animations laufen 60 FPS. Change Detection bei jedem Frame ist overkill. Detach und manuell triggern alle 100ms ist effizienter.

12.10 NgZone: Running Outside Angular

NgZone ist Angular’s Wrapper um Zone.js. Es ermöglicht Code-Execution outside Angular’s Zone:

@Component({
  selector: 'app-clock',
  template: `<div>{{ time }}</div>`
})
export class ClockComponent {
  time = new Date().toLocaleTimeString();
  
  constructor(private ngZone: NgZone) {}
  
  startClock() {
    this.ngZone.runOutsideAngular(() => {
      setInterval(() => {
        this.time = new Date().toLocaleTimeString();
        
        // CD manuell triggern (z.B. jede Sekunde)
        this.ngZone.run(() => {});
      }, 1000);
    });
  }
}

Der setInterval läuft outside Angular’s Zone. Zone.js detektiert ihn nicht. Change Detection wird nicht automatisch getriggert. Dies verhindert unnötige Cycles.

ngZone.run() führt Code inside Angular’s Zone aus. Dies triggert Change Detection. Im Beispiel wird CD einmal pro Sekunde getriggert statt bei jedem Interval-Tick.

Dies ist ein advanced Pattern für Performance-kritische Scenarios. Die meisten Components sollten innerhalb Angular’s Zone laufen.

12.11 Signals: Die Zukunft der Reactivity

Angular Signals sind ein neues Reactivity-Primitive. Sie bieten fine-grained Reactivity ohne die Complexity von Observables oder Zone.js.

Ein Signal ist ein Wert-Container mit Notification:

import { signal, computed, effect } from '@angular/core';

@Component({
  selector: 'app-counter',
  template: `
    <div>Count: {{ count() }}</div>
    <div>Doubled: {{ doubled() }}</div>
    <button (click)="increment()">+</button>
  `
})
export class CounterComponent {
  count = signal(0);
  doubled = computed(() => this.count() * 2);
  
  constructor() {
    effect(() => {
      console.log('Count changed:', this.count());
    });
  }
  
  increment() {
    this.count.update(n => n + 1);
  }
}

Signals tracked automatisch Dependencies. Das doubled Signal re-computes wenn count ändert. Der effect läuft bei jeder count-Änderung.

Im Template wird count() called – dies ist ein Signal-Read. Angular tracked diesen Read. Wenn das Signal updated, wird nur dieser spezifische Teil des Templates re-rendered. Nicht die gesamte Component, nur der Binding-Expression.

Dies ist drastisch effizienter als traditional Change Detection. Kein Tree-Walk, keine Expression-Re-Evaluation für ungeänderte Werte. Nur affected Bindings updaten.

12.11.1 Signals mit OnPush

Signals funktionieren perfekt mit OnPush:

@Component({
  selector: 'app-user',
  template: `
    <div>Name: {{ user().name }}</div>
    <div>Age: {{ user().age }}</div>
    <button (click)="birthday()">Birthday</button>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class UserComponent {
  user = signal({ name: 'John', age: 30 });
  
  birthday() {
    this.user.update(u => ({ ...u, age: u.age + 1 }));
  }
}

Signal-Updates triggern Change Detection für die Component. Kein markForCheck() nötig. Signals integrieren nahtlos mit beiden Strategies.

12.11.2 Signals vs. Observables

Signals und Observables sind complementary, nicht competitive. Signals sind synchron und lightweight. Observables sind asynchron und feature-rich.

Signals eignen sich für synchronen Component-State:

selectedId = signal<number | null>(null);
items = signal<Item[]>([]);

Observables eignen sich für asynchrone Data-Streams:

users$ = this.http.get<User[]>('/api/users');
search$ = this.searchControl.valueChanges.pipe(
  debounceTime(300),
  switchMap(term => this.searchService.search(term))
);

Die Kombination ist mächtig:

@Component({
  template: `
    @if (users$ | async; as users) {
      <div *ngFor="let user of users" 
           [class.selected]="user.id === selectedId()">
        {{ user.name }}
      </div>
    }
  `
})
export class UserListComponent {
  selectedId = signal<number | null>(null);
  users$ = this.userService.getUsers();
}

Observables für async HTTP, Signals für sync Component-State.

12.12 Immutability und Predictability

OnPush und Signals erzwingen Immutability-Patterns. Dies ist ein Constraint aber auch ein Feature. Immutable Updates machen Code predictable.

Ein mutable Update:

// Component A
this.sharedState.count++;

// Component B - unerwartete Änderung
console.log(this.sharedState.count); // ???

Component B’s State ändert sich unexpectedly. Dies ist ein Action-at-a-Distance-Problem. Debugging ist schwierig – wer hat den State mutiert?

Ein immutable Update:

// Component A
const newState = { ...this.sharedState, count: this.sharedState.count + 1 };
this.stateService.updateState(newState);

// Component B subscribed den State Service
this.stateService.state$.subscribe(state => {
  console.log(state.count); // Explicit update
});

Component B empfängt einen expliziten Update-Notification. Der State-Flow ist transparent. Debugging ist einfach – folge dem Observable-Stream.

Immutability macht Time-Travel-Debugging möglich. Jeder State ist eine Snapshot. Man kann backwards durch History navigieren. Redux DevTools demonstrieren dies.

12.13 Performance-Patterns

12.13.1 TrackBy für NgFor

*ngFor rendert Lists. Ohne trackBy destroyed und recreated Angular DOM-Nodes bei jedem Array-Update:

@Component({
  template: `
    <div *ngFor="let item of items">{{ item.name }}</div>
  `
})
export class ListComponent {
  items = [{ id: 1, name: 'A' }, { id: 2, name: 'B' }];
  
  refresh() {
    this.items = [{ id: 1, name: 'A' }, { id: 2, name: 'B' }]; // Neue Referenz
  }
}

refresh() kreiert ein neues Array mit gleichen Daten. Angular sieht neue Object-Referenzen und destroyed/recreated alle DOM-Nodes. Dies ist ineffizient.

trackBy gibt Angular einen Identity-Hint:

@Component({
  template: `
    <div *ngFor="let item of items; trackBy: trackById">
      {{ item.name }}
    </div>
  `
})
export class ListComponent {
  items = [{ id: 1, name: 'A' }, { id: 2, name: 'B' }];
  
  trackById(index: number, item: any): number {
    return item.id;
  }
  
  refresh() {
    this.items = [{ id: 1, name: 'A' }, { id: 2, name: 'B' }];
  }
}

Angular nutzt id als Identity. Trotz neuer Array-Referenz erkennt Angular dass die Items gleich sind (gleiche id). DOM-Nodes werden reused, nicht recreated.

Dies ist kritisch für Performance bei large Lists. Ohne trackBy ist jedes Update ein complete Re-Render.

12.13.2 Virtual Scrolling für große Listen

Tausende Items in DOM sind langsam, egal wie optimiert Change Detection ist. Virtual Scrolling rendert nur sichtbare Items:

import { ScrollingModule } from '@angular/cdk/scrolling';

@Component({
  template: `
    <cdk-virtual-scroll-viewport itemSize="50" class="viewport">
      <div *cdkVirtualFor="let item of items" class="item">
        {{ item.name }}
      </div>
    </cdk-virtual-scroll-viewport>
  `,
  styles: [`
    .viewport { height: 400px; }
    .item { height: 50px; }
  `],
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class VirtualListComponent {
  items = Array.from({ length: 100000 }, (_, i) => ({ 
    id: i, 
    name: `Item ${i}` 
  }));
}

Nur ~10 Items sind im DOM, auch bei 100k Items im Array. Scrolling updates den DOM-Window. Change Detection ist minimal – nur sichtbare Items werden gechecked.

Dies kombiniert gut mit OnPush. Large Lists werden performant.

12.14 Zoneless Angular

Angular’s Dependency auf Zone.js ist historisch. Modern Frameworks wie Solid und Svelte nutzen fine-grained Reactivity ohne global Monkey-Patching. Angular bewegt sich in diese Richtung.

Zoneless Angular (experimental) removed Zone.js Dependency:

import { bootstrapApplication } from '@angular/platform-browser';
import { provideExperimentalZonelessChangeDetection } from '@angular/core';

bootstrapApplication(AppComponent, {
  providers: [
    provideExperimentalZonelessChangeDetection()
  ]
});

Ohne Zone.js muss Change Detection manuell getriggert werden oder via Signals. Signals sind der preferred Path – sie bieten automatische fine-grained Updates ohne Zone.js overhead.

Zoneless ist die Zukunft. Signals werden zum primary Reactivity-Primitive. Zone.js bleibt für Backwards-Compatibility aber neue Apps sollten Signals nutzen.

Change Detection ist Angular’s Mechanism für State-View-Synchronization. Die Default-Strategy ist simple aber inefficient. OnPush optimiert via Referenz-Equality und Input-Checks. Signals bieten fine-grained Reactivity ohne Zone.js. Die Evolution geht von global Change Detection zu local reactive Updates. Performance-critical Apps sollten OnPush + Signals nutzen. Immutability ist key – mutable Updates brechen OnPush. TrackBy und Virtual Scrolling optimieren Lists. Die Zukunft ist Zoneless mit Signal-basierter Reactivity.