Mit Version 17 hat Angular ein neues reaktives Fundament eingeführt: Signals. Ein Signal ist ein Wertcontainer, der seine Leser kennt — und sie automatisch benachrichtigt, sobald sich der Wert ändert. Statt einen globalen Change-Detection-Cycle anzustoßen, weiß Angular jetzt exakt, welche Stellen im UI auf welche Daten reagieren.
Dieser Artikel ist der Einstieg ins Kapitel Reaktivität. Versions-Baseline ist Angular 21 (Nov 2025): Signals sind seit v17 stable, mit v22 wird OnPush der Default und Zoneless graduiert weiter — eine Welt, in der Signals zur ersten Wahl werden.
Definition: Wertcontainer mit eingebauter Abhängigkeitsverfolgung
Ein Signal ist ein Wrapper um einen Wert. Du liest den Wert, indem du das Signal als Funktion aufrufst (count()), und du schreibst ihn über count.set(...) oder count.update(...). So weit klingt das nach einem etwas umständlichen Getter/Setter-Paar. Der Unterschied steckt unter der Oberfläche: Bei jedem Lese-Aufruf merkt sich Angular, wer gelesen hat — und sobald der Wert sich ändert, benachrichtigt es exakt diese Leser.
Das macht ein Signal zu einem Reactive Primitive: dem kleinsten Baustein einer reaktiven Architektur. Alle weiteren Konzepte — computed(), effect(), linkedSignal(), resource() — bauen auf diesem einen Mechanismus auf.
// Klassisch: Property + manuelle Change Detection
import { ChangeDetectorRef, Component } from '@angular/core';
@Component({
selector: 'app-classic-counter',
template: `
<p>{{ count }}</p>
<button (click)="inc()">+1</button>
`,
})
export class ClassicCounterComponent {
count = 0;
constructor(private cdr: ChangeDetectorRef) {}
inc() {
this.count++;
// Bei OnPush muss man die Detection explizit anstoßen
this.cdr.markForCheck();
}
}
// Signal: kein cdr, kein markForCheck — Angular weiß, wer liest
import { Component, signal } from '@angular/core';
@Component({
selector: 'app-signal-counter',
template: `
<p>{{ count() }}</p>
<button (click)="inc()">+1</button>
`,
})
export class SignalCounterComponent {
readonly count = signal(0);
inc() {
this.count.update((c) => c + 1);
}
}Zwei Reaktivitäts-Modelle, ein Ergebnis
Reaktive Systeme lassen sich in zwei Lager einteilen: Push (das System schickt neue Werte aktiv an alle Abonnenten) und Pull (das System markiert nur „etwas hat sich geändert” und überlässt den Lesern das Holen). RxJS ist klassisch Push: Ein BehaviorSubject ruft next() auf, jeder Subscriber bekommt den neuen Wert in den Callback gereicht. Signals dagegen sind Pull mit Tracking: Der reaktive Graph notiert sich, wer welche Signals gelesen hat, und beim Update werden diese Leser als „dirty” markiert — sie holen sich den Wert beim nächsten Render-Cycle selbst ab.
Der Vorteil ist subtil, aber groß: Ein Pull-System kennt seinen aktuellen Wert immer synchron — mySignal() gibt jetzt sofort die Wahrheit. Ein Push-System hingegen kann nur „den letzten emittierten Wert” abfragen (BehaviorSubject.value), und auch nur, wenn das Subject ein Behavior ist.
| Aspekt | Push (Observables, RxJS) | Pull mit Tracking (Signals) |
|---|---|---|
| Lese-Modell | subscribe(value => ...) | mySignal() direkt |
| Aktueller Wert | Nur via Behavior/.value oder Subscribe | Immer synchron verfügbar |
| Dependency Tracking | Manuell (Pipe-Komposition) | Automatisch (Graph-basiert) |
| Async-Modellierung | Erstklassig (Operatoren, Streams) | Nicht direkt — via rxjs-interop |
| Memory-Management | Subscription teardown nötig | Kein Subscribe — kein Leak möglich |
| Geeignet für | Streams, Async-Pipelines, Events | UI-State, abgeleitete Werte, Komponenten |
Vor Signals: Zone.js patcht die Welt
Vor Angular 17 war Change Detection ein globaler Mechanismus, der auf Zone.js basierte. Zone.js patcht beim App-Start fast alle asynchronen Browser-APIs (setTimeout, Promise.then, addEventListener, XMLHttpRequest, …) und ruft nach jedem solchen Ereignis Angulars tick() auf. Das löst einen kompletten Top-Down-Durchlauf des Component-Trees aus, in dem Angular alle Bindings re-evaluiert, auch jene, deren Daten sich gar nicht geändert haben.
Das war pragmatisch und hat „einfach funktioniert”, aber es hatte Kosten: ein 30 KB großer Polyfill, Patching von Browser-APIs (mit Verträglichkeitsproblemen für andere Libraries), und Change-Detection-Overhead bei jeder Async-Operation, selbst wenn sie nichts mit der UI zu tun hatte.
Mit Signals macht Angular einen entscheidenden Schritt weiter: Nur Komponenten, deren Signals sich geändert haben, werden neu evaluiert. Statt jedes Binding neu zu prüfen, weiß der Reaktivitäts-Graph präzise, welcher Subtree betroffen ist. Mit provideZonelessChangeDetection() kannst du Zone.js komplett aus dem Bundle werfen.
import { bootstrapApplication } from '@angular/platform-browser';
import { provideZonelessChangeDetection } from '@angular/core';
import { AppComponent } from './app/app.component';
// Zone.js raus — Signals und Events triggern die Detection
bootstrapApplication(AppComponent, {
providers: [provideZonelessChangeDetection()],
});signal(), computed(), effect()
Die Signal-API zerfällt in drei Bausteine, die jeweils eine klar abgegrenzte Aufgabe haben. Du wirst sie in jedem reaktiven Code zusammen sehen — und im Idealfall verstehst du nach diesem Kapitel auf den ersten Blick, welcher Baustein wo gehört.
| Funktion | Schreibt? | Liest? | Zweck |
|---|---|---|---|
signal<T>(init) | ja | ja | Quelle: speichert einen Wert, hält Leser fest |
computed(() => ...) | nein | ja | Ableitung: rechnet aus anderen Signals einen Wert |
effect(() => ...) | nein | ja | Side-Effect: reagiert auf Änderungen mit Aktion |
import { Component, computed, effect, signal } from '@angular/core';
@Component({
selector: 'app-three-primitives',
standalone: true,
template: `
<p>{{ firstName() }} {{ lastName() }}</p>
<p>Voller Name: {{ fullName() }}</p>
<button (click)="rename()">Umbenennen</button>
`,
})
export class ThreePrimitivesComponent {
// 1. Quelle — schreibbar
readonly firstName = signal('Ada');
readonly lastName = signal('Lovelace');
// 2. Ableitung — read-only, automatisch aktualisiert
readonly fullName = computed(() => `${this.firstName()} ${this.lastName()}`);
// 3. Side-Effect — läuft, wenn fullName neu berechnet wurde
constructor() {
effect(() => {
console.log('Name geändert zu:', this.fullName());
});
}
rename() {
this.firstName.set('Grace');
this.lastName.set('Hopper');
}
}Ein vollständiger Counter
Der „Hello World” der Signals-Welt ist der Counter. Er zeigt alle Mechaniken in einer Komponente: Deklaration, Lese-Aufruf im Template, Schreib-Aufruf im Event-Handler, automatischer Re-Render.
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
@Component({
selector: 'app-counter',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="counter">
<h2>Counter</h2>
<p class="value">Wert: {{ count() }}</p>
<div class="controls">
<button (click)="dec()">−1</button>
<button (click)="reset()">Reset</button>
<button (click)="inc()">+1</button>
</div>
</section>
`,
styles: [`
.counter { padding: 1rem; border: 1px solid #ddd; border-radius: 8px; }
.value { font-size: 1.5rem; }
.controls { display: flex; gap: 0.5rem; }
`],
})
export class CounterComponent {
// readonly verhindert Reassignment des Signal-Objekts;
// der Wert dahinter ist trotzdem schreibbar
readonly count = signal(0);
inc() {
this.count.update((c) => c + 1);
}
dec() {
this.count.update((c) => c - 1);
}
reset() {
this.count.set(0);
}
}Drei Dinge zum Mitnehmen aus diesem Beispiel: Erstens, das readonly betrifft das Property, nicht den Wert. Zweitens, im Template steht count() mit Klammern — der Lese-Aufruf ist eine Funktion. Drittens, die Komponente nutzt OnPush, und trotzdem rendert sie bei jedem Click neu — weil das Signal Angular sagt, dass sich der Wert geändert hat.
Klammern sind Pflicht
Da ein Signal eine Funktion ist, musst du im Template count() schreiben — nicht count. Lässt du die Klammern weg, rendert Angular den toString() des Funktions-Objekts. Das ist ein häufiger Anfänger-Bug, weil das Template kommentarlos einen merkwürdigen String zeigt statt eines Werts.
<!-- FALSCH: rendert "function bound boundFn() { ... }" oder ähnlich -->
<p>{{ count }}</p>
<!-- RICHTIG: Klammern lesen den aktuellen Wert -->
<p>{{ count() }}</p>
<!-- Auch in Bindings: Klammern -->
<button [disabled]="count() >= max()">Max erreicht</button>
<!-- In @if / @for: Klammern -->
@if (count() > 0) {
<span>aktiv</span>
}
@for (item of items(); track item.id) {
<li>{{ item.name }}</li>
}Geteilter State über einen Service
Ein Signal ist erstmal nur eine Variable. Erst wenn du das Signal aus einem @Injectable-Service exportierst und in mehreren Komponenten konsumierst, hast du echten geteilten State — ohne RxJS, ohne Subjects, ohne Subscriptions.
import { Injectable, computed, signal } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class CounterService {
// Privater Schreib-Zugang
private readonly _count = signal(0);
// Öffentliche Read-only-Sicht — niemand kann von außen .set() rufen
readonly count = this._count.asReadonly();
// Abgeleiteter Wert
readonly isPositive = computed(() => this._count() > 0);
inc() {
this._count.update((c) => c + 1);
}
dec() {
this._count.update((c) => c - 1);
}
reset() {
this._count.set(0);
}
}import { Component, inject } from '@angular/core';
import { CounterService } from './counter.service';
@Component({
selector: 'app-counter-display',
standalone: true,
template: `
<p>Wert: {{ counter.count() }}</p>
<p>Ist positiv: {{ counter.isPositive() }}</p>
`,
})
export class CounterDisplayComponent {
readonly counter = inject(CounterService);
}
@Component({
selector: 'app-counter-controls',
standalone: true,
template: `
<button (click)="counter.dec()">−</button>
<button (click)="counter.reset()">0</button>
<button (click)="counter.inc()">+</button>
`,
})
export class CounterControlsComponent {
readonly counter = inject(CounterService);
}Beide Komponenten teilen jetzt automatisch den Counter-State — Klick im Controls-Component aktualisiert die Anzeige im Display-Component, ohne dass irgendwo subscribe, unsubscribe oder BehaviorSubject vorkommt.
Warum Signals und OnPush perfekt zusammenpassen
ChangeDetectionStrategy.OnPush weist Angular an, eine Komponente nur dann neu zu evaluieren, wenn eine ihrer Eingaben sich geändert hat oder ein DOM-Event in ihrem Subtree gefeuert wurde. Vor Signals war das fragil: Mutiertest du ein Input-Object in Place, sah Angular keine neue Reference und rendert nicht — du brauchtest markForCheck() oder unveränderliche Daten.
Signals lösen das elegant: Liest ein Template ein Signal, registriert sich die Komponente automatisch als Konsument. Ändert sich das Signal, markiert Angular die Komponente als dirty — auch unter OnPush, auch zoneless. Du musst nichts tun.
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
@Component({
selector: 'app-onpush-signal',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<p>Tick: {{ tick() }}</p>
<button (click)="bump()">Tick</button>
`,
})
export class OnPushSignalComponent {
readonly tick = signal(0);
bump() {
// Kein cdr.markForCheck() nötig — das Signal regelt das
this.tick.update((t) => t + 1);
}
}Signals sind das Fundament — nicht nur ein State-Container
Sobald Signals in v17 stabil wurden, hat das Angular-Team begonnen, alle Kern-APIs auf dieses Fundament zu heben. Was du in den nächsten Kapiteln lernst, ist im Kern dasselbe Modell:
- Inputs / Outputs / Models (Kapitel 3):
input(),output(),model()sind Signal-basierte Versionen der alten@Input/@Output-Dekoratoren — typsicher, ohne Boilerplate, mit Two-Way-Binding viamodel(). - Queries (Kapitel 5):
viewChild()undcontentChild()sind Signal-Queries, die du wie ein normales Signal liest — keine@ViewChild-Lifecycle-Tricks mehr. - Resources / rxResource (Kapitel 8):
resource()undrxResource()modellieren async-Daten als Signal — Loading, Error und Value sind je ein Signal-Property. - Signal Forms (Kapitel 12): In v21 graduiert Signal Forms aus der Developer Preview — eine vollständige Form-API, die intern auf Signals statt auf
FormControl-Objekten basiert. - Linked Signal:
linkedSignal()(v19) ist ein hybrider Baustein, der einen Wert aus anderen Signals ableitet, aber auch direkt überschrieben werden kann — ideal für „Default + User-Override”-Szenarien. - toSignal / toObservable: Aus dem Paket
@angular/core/rxjs-interopbrückt Signals und RxJS in beide Richtungen — du musst nicht „eines oder das andere” wählen.
import { Component, computed, input, model, output, viewChild } from '@angular/core';
@Component({
selector: 'app-tag-editor',
standalone: true,
template: `
<input #ref [value]="text()" (input)="text.set($any($event.target).value)" />
<p>{{ greeting() }}</p>
<button (click)="commit.emit(text())">OK</button>
`,
})
export class TagEditorComponent {
readonly initial = input.required<string>(); // Signal-Input
readonly text = model<string>(''); // Two-Way Signal
readonly commit = output<string>(); // Signal-Output
readonly inputEl = viewChild<HTMLInputElement>('ref'); // Signal-Query
readonly greeting = computed(() => `Hallo, ${this.text()}`);
}Wissenswertes über Signals
Signals tracken Lese-Aufrufe automatisch
Innerhalb eines reaktiven Kontexts (computed, effect, Template) registriert sich jedes mySignal() als Dependency des umgebenden Kontexts. Du musst keine Liste pflegen, kein subscribe rufen — der Compiler/Runtime baut den Dependency-Graph für dich.
Signals sind Pull mit Tracking — nicht Push
Anders als bei Observables wird der Wert nicht „rausgeschickt”, sondern beim nächsten Lese-Aufruf gepullt. Der reaktive Graph weiß nur, „etwas hat sich geändert” und markiert die Konsumenten als dirty. Das macht Signals leichter, billiger und memory-freundlicher als Subjects.
Inspiration aus SolidJS
Das Konzept ist nicht neu — SolidJS hat es seit Jahren mit createSignal/createMemo/createEffect. Angular hat das Modell adaptiert und tief in die Framework-Ebene gezogen, sodass OnPush und Zoneless zur natürlichen Konsequenz werden.
Signals + Zoneless = kleineres Bundle
Mit provideZonelessChangeDetection() fliegt Zone.js (rund 30 KB) komplett raus. Voraussetzung: Dein State läuft konsequent über Signals oder via toSignal() aus RxJS-Streams. Native Browser-APIs werden nicht mehr gepatcht — Drittbibliotheken laufen damit oft kompatibler.
Templates sind ein Reaktivitäts-Boundary
Du musst Signals nicht in computed/effect einbetten, um sie im Template zu lesen. Der Template-Compiler erzeugt selbst einen reaktiven Kontext rund um jede Binding-Expression — {{ user().name }} reicht aus, damit das Template auf Änderungen reagiert.
Signal-Wert ist immer synchron der aktuelle Wert
mySignal() gibt jetzt sofort den aktuellen Wert zurück — kein „last emitted”, kein „first run delay”. Damit eignen sich Signals hervorragend für UI-State, der innerhalb eines Render-Cycles deterministisch sein muss.
Signals sind keine Schleifen-Killer
Bei extremen Update-Raten (Tausende pro Sekunde, etwa Mausbewegungen für Canvas-Zeichnen) ist RxJS mit Subject + throttleTime oder debounceTime immer noch das richtige Werkzeug. Konvertiere am Ende per toSignal() zurück, um die Schnittstelle zur Komponente clean zu halten.
Kein Subscribe — kein Memory-Leak
Da niemand auf ein Signal „subscribe-d”, gibt es auch nichts zu „unsubscribe-en”. effect() räumt sich automatisch auf, wenn der Injector zerstört wird — und Templates lösen ihre Dependencies beim Component-Destroy von selbst.
Weiterführende Ressourcen
Externe Quellen
- Angular Signals – Guide
- signal() – API Reference
- Signal – API Reference
- WritableSignal – API Reference
- provideZonelessChangeDetection() – API