Reaktivität in Angular: Observables vs. Signals

Einführung in die Reaktivität mit Angular

Auf den ersten Blick könnte man denken, dass Signals und Observables in Angular einfach vergleichbar sind, da beide Reaktivität ermöglichen. Schaut man aber genauer hin, sollte die Frage eher lauten: „Wann verwende ich was?“ statt „Welches sollte ich verwenden?“.

Hinweis: Wann immer ich über die Möglichkeiten von Observables spreche, inkludiere ich auch RxJS, eine unabhängige Bibliothek für Reaktivität, die vor Allem in der Angular-Welt stark verbreitet ist.

Der direkte Vergleich

Was sie gemeinsam haben

  • Beide finden Anwendung in reaktiven Kontexten

Worin sie sich unterscheiden

  • Synchron vs Asynchron: Signals reagieren immer synchron auf Änderung, Observables meist asynchron – können jedoch auch synchron agieren (z.B. mit dem Operator of)
  • Verwaltung von Abhängigkeiten: Signals können von anderen Signals abgeleitet werden. Angular verwaltet diese Abhängigkeiten automatisch und aktualisiert die abgeleiteten Werte direkt nach Änderungen der Quell-Werte. Observables können ebenfalls von anderen Observables abhängig sein, allerdings müssen diese explizit subscribe(d) werden. Diese Subscription muss dann auch wieder korrekt aufgelöst werden.
  • Propagieren von Änderungen: Signals lösen nur dann eine Reaktion aus, wenn sich der eigentliche Wert ändert. Sollte ein Signal auf den bestehenden Wert gesetzt werden (const a = signal(1); a.set(1);), so passiert nichts, da keine Wertänderung stattgefunden hat. Bei Oberservables hingegen, wird jeder Wert emittiert, es sei denn, man konfiguriert das Observable explizit anders (z.B. mit distinctUntilChanged).
  • Initial-Wert: Signals haben immer einen initialen Wert, während Observables möglicherweise niemals emittieren können.

Wie man erkennen kann, haben zwar beide mit Reaktivität zu tun, aber direkt vergleichbar (im Sinne einer Pro/Contra-Liste) sind sie nicht. Die Frage, ob man Signals oder Observables verwenden sollte, stellt sich aktuell im Grunde nur, da Observables (mit RxJS) so umfangreich und mächtig sind, dass man sie oftmals anstatt Signals nutzen kann – umgekehrt ist dies jedoch eher selten der Fall.

Zusammengefasst: Signals bieten bieten eine einfache und klare Lösung für einen sehr bestimmten Anwendungsbereich, während Observables einen breiteren, weniger spezifischen Anwendungsbereich abdecken können, dafür jedoch auch deutlich komplexer in der Anwendung sind.

Wann verwende ich was?

Signals finden ihren Hauptanwendungsbereich beim Verwalten von lokalen, synchronen Zuständen innerhalb von Komponenten. Observables sind ideal, um asynchrone Datenströme zu verwalten, wie beispielsweise HTTP-Anfragen, WebSockets oder Nutzereingaben.

Konkrete Beispiele

Die folgenden zwei Beispiele sollen die Anwendungsfälle für Signals und Observables exemplarisch veranschaulichen.

Zähler (mit Signals)

import { Component, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';

@Component({
  selector: 'app-counter',
  standalone: true, 
  imports: [CommonModule],
  template: `
    <h1>Counter: {{ count() }}</h1>
    <h2>Double Count: {{ doubleCount() }}</h2> 
    <button (click)="increment()">Increment</button>
    <button (click)="decrement()">Decrement</button>
  `,
})
export class CounterComponent {
  count = signal(0);
  doubleCount = computed(() => this.count() * 2);

  increment() {
    this.count.update(value => value + 1);
  }

  decrement() {
    this.count.update(value => value - 1);
  }
}

Zähler (mit Observables)

import { Component } from '@angular/core';
import { CommonModule, AsyncPipe } from '@angular/common';
import { BehaviorSubject } from 'rxjs';
import { map } from 'rxjs/operators';

@Component({
  selector: 'app-counter',
  standalone: true,
  imports: [CommonModule, AsyncPipe],
  template: `
    <h1>Counter: {{ count$ | async }}</h1>
    <h2>Double Count: {{ doubleCount$ | async }}</h2> 
    <button (click)="increment()">Increment</button>
    <button (click)="decrement()">Decrement</button>
  `,
})
export class CounterComponent {
  private countSubject = new BehaviorSubject<number>(0);

  count$ = this.countSubject.asObservable();
  doubleCount$ = this.count$.pipe(
    map(value => value * 2) 
  );

  increment() {
    this.countSubject.next(this.countSubject.value + 1);
  }

  decrement() {
    this.countSubject.next(this.countSubject.value - 1);
  }
}

Ein wichtiger Punkt hier ist die Nutzung der AsyncPipe, die den Prozess des subscribe und unsubscribe automatisch übernimmt.

Betrachtet man nur die Anzahl an Codezeilen, sehen beide Lösungen ziemlich ähnlich aus. Allerdings ist die Variante mit Signals insgesamt leichter verständlich, da die Verwendung von Signals der Verwendung von „normalen“ Variablen ähnelt, während das korrekte Verwenden von Observables (und RxJS) mehr Wissen über Angular und die RxJS-Bibliothek erfordert.

Beispiel mit mehreren Quell-Daten

Die Unterschiede werden stärker verdeutlicht, wenn man den abgeleiteten Wert von zwei statt einem Quell-Wert abhängig macht.

Mit Signals
import { Component, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';

@Component({
  selector: 'app-counter',
  standalone: true,
  imports: [CommonModule],
  template: `
    <h1>Count 1: {{ count1() }}</h1>
    <h1>Count 2: {{ count2() }}</h1>
    <h2>Total Count: {{ totalCount() }}</h2>
    <button (click)="incrementCount1()">Increment Count 1</button>
    <button (click)="incrementCount2()">Increment Count 2</button>
  `,
})
export class CounterComponent {
  count1 = signal(0);
  count2 = signal(0);

  totalCount = computed(() => this.count1() + this.count2());

  incrementCount1() {
    this.count1.update(value => value + 1);
  }

  incrementCount2() {
    this.count2.update(value => value + 1);
  }
}
Mit Observables
import { Component } from '@angular/core';
import { CommonModule, AsyncPipe } from '@angular/common';
import { BehaviorSubject, combineLatest } from 'rxjs';
import { map } from 'rxjs/operators';

@Component({
  selector: 'app-counter',
  standalone: true,
  imports: [CommonModule, AsyncPipe],
  template: `
    <h1>Count 1: {{ count1$ | async }}</h1>
    <h1>Count 2: {{ count2$ | async }}</h1>
    <h2>Total Count: {{ totalCount$ | async }}</h2>
    <button (click)="incrementCount1()">Increment Count 1</button>
    <button (click)="incrementCount2()">Increment Count 2</button>
  `,
})
export class CounterComponent {
  private count1Subject = new BehaviorSubject<number>(0);
  private count2Subject = new BehaviorSubject<number>(0);

  count1$ = this.count1Subject.asObservable();
  count2$ = this.count2Subject.asObservable();

  totalCount$ = combineLatest([this.count1$, this.count2$]).pipe(
    map(([count1, count2]) => count1 + count2)
  );

  incrementCount1() {
    this.count1Subject.next(this.count1Subject.value + 1);
  }

  incrementCount2() {
    this.count2Subject.next(this.count2Subject.value + 1);
  }
}

In der Signals-Variante genügt es, die computed-Eigenschaft anzupassen, um beide Signal-Quellen in die Berechnung einzubeziehen. Angular erledigt den Rest automatisch.

In der Observable-Variante müssen wir jedoch zusätzlich den combineLatest-Operator verwenden, um die beiden Streams zusammenzuführen. Das macht die Lösung etwas komplizierter, vor allem für Entwickler, die noch nicht mit RxJS vertraut sind.

Nutzereingaben verarbeiten

Das Verarbeiten von Benutzereingaben erfordert oft aufwendige Operationen, wie zum Beispiel API-Aufrufe beim Verwenden einer Suchmaske. Eine gängige Lösung für dieses Problem ist Debouncing. Es sorgt dafür, dass API-Aufrufe nur dann ausgeführt werden, wenn der Benutzer für einen festgelegten Zeitraum aufgehört hat zu tippen. Debouncing enthält dementsprechend eine zeitliche Komponente, welche abhängig von äußeren Einflüssen (Nutzereingabe) ist.

In den folgenden zwei Beispielen wird verdeutlicht, warum RxJS für solch einen Anwendungsfall prädestiniert ist und Signals nicht.

Verwendung von RxJS für Debouncing
import { Component } from '@angular/core';
import { CommonModule, AsyncPipe } from '@angular/common';
import { Subject } from 'rxjs';
import { debounceTime, distinctUntilChanged } from 'rxjs/operators';

@Component({
  selector: 'app-search',
  standalone: true,
  imports: [CommonModule, AsyncPipe],
  template: `
    <input (input)="onSearch($event)" placeholder="Search" />
    <p>Search Query: {{ searchQuery$ | async }}</p>
  `,
})
export class SearchComponent {
  private searchSubject = new Subject<string>(); 

  searchQuery$ = this.searchSubject.pipe(
    debounceTime(300),  // Debounce input events, until no event occurs for 300ms
    distinctUntilChanged()  // Only emit if the value actually changed
  );

  onSearch(event: Event) {
    const query = (event.target as HTMLInputElement).value;
    this.searchSubject.next(query);  
  }
}

Mit RxJS lässt sich das Problem mittels der eingebauten Operatoren debounce und distinctUntilChanged trivial lösen. Dadurch transportiert der geschriebene Code auch gut die Intention des/der Entwickler:in.

Verwendung von Signals für Debouncing
import { Component, signal } from '@angular/core';
import { CommonModule } from '@angular/common';

@Component({
  selector: 'app-search',
  standalone: true,
  imports: [CommonModule],
  template: `
    <input (input)="onSearch($event)" placeholder="Search" />
    <p>Search Query: {{ searchQuery() }}</p>
  `,
})
export class SearchComponent {
  searchQuery = signal(''); 
  timeoutId: any = null;

  onSearch(event: Event) {
    const query = (event.target as HTMLInputElement).value;

    // Implementing debouncing manually
    if (this.timeoutId) {
      clearTimeout(this.timeoutId);
    }

    this.timeoutId = setTimeout(() => {
      this.searchQuery.set(query);  // Update the signal after debounce
    }, 300);
  }
}

Wie man sieht, ist die onSearch-Funktion mit Signals weniger übersichtlich und wirkt unstrukturierter. Dies kommt daher, da es keine eingebaute Möglichkeit gibt Signals zu debouncen, da diese inhärent synchron sind. Dadurch muss die Debounce-Logik umständlich selbst implementiert werden.

Der deklarative Ansatz von RxJS macht sofort deutlich, was die Intention sein soll. Die imperative Signal-Implementierung hingegen erschwert es, zu verstehen, was genau erreicht werden soll. Dies wird noch deutlicher, wenn die Komplexität der Stream-Manipulation zunimmt.

Fazit

Sowohl Signals als auch Observables handhaben Reaktivität, sie haben jedoch unterschiedliche Anwendungsbereiche.

Signals sind hervorragend geeignet, um lokale, synchrone Zustände zu verwalten. Sie bieten eine einfache Möglichkeit Änderungen innerhalb von Komponenten zu verfolgen und auf diese zu reagieren – und schaffen so, vor Allem für Einsteiger, ein tolles Entwicklungserlebnis.

Observables hingegen bleiben die bevorzugte Lösung für komplexere, asynchrone Datenströme, wie z.B. API-Anfragen oder zeitbasierte Ereignisse.

Ausblick

Diese Differenzierung wird so schnell nicht verschwinden. Allerdings hat das Angular-Team den Plan langfristig RxJS vollständig optional für die Entwicklung komplexer Angular Anwendungen zu machen (Angular Core Team Q&A Session & Building Games and Q/A with the Angular Team). Ziel ist es, neue APIs einzuführen, welche Signals anstelle von Observables verwenden. Diese Pläne befinden sich jedoch noch in der Entwicklung und sind weit davon entfernt, final zu sein.

Bis dahin werden wir weiterhin mit beiden, Signals und Observables, arbeiten und fundierte Entscheidungen darüber treffen, wann welches Werkzeug zum Einsatz kommt, basierend auf den Anforderungen des jeweiligen Anwendungsfalls.

Allgemeine Anfrage

Wir freuen uns darauf, Ihre Herausforderungen zusammen in Angriff zu nehmen und über passende Lösungsansätze zu sprechen. Kontaktieren Sie uns – und erhalten Sie maßgeschneiderte Lösungen für Ihr Unternehmen. Wir freuen uns auf Ihre Kontaktanfrage!

Jetzt Kontakt aufnehmen