Ein Signal ist eine Box, die einen Wert hält und alle, die ihn gelesen haben, automatisch benachrichtigt, sobald er sich ändert. Angular führt Buch: Wenn das Template, ein computed() oder ein effect() count() liest, hängt es von diesem Signal ab. Ändert sich count, wird nur aktualisiert, was davon abhängt — ohne Zone.js und ohne Prüfung des gesamten Komponentenbaums.
Ab v21 sind neue Anwendungen zoneless, und ab v22 sind Komponenten standardmäßig OnPush. Signals sind also keine „moderne Option“ mehr — sie sind der zentrale Mechanismus, über den Angular weiß, was neu gerendert werden muss. Wenn du Zustand in einfachen Feldern hältst und ihn aus einem setTimeout oder einem subscribe heraus änderst, aktualisiert sich die UI womöglich nicht mehr.
Die drei Grundoperationen
Du erstellst ein Signal mit signal(initialValue), liest es, indem du es wie eine Funktion aufrufst, und schreibst mit set() oder update(). Abgeleiteter Zustand entsteht mit computed().
import { computed, signal, Signal, WritableSignal } from '@angular/core';
const name: WritableSignal<string> = signal('Ana');
name(); // Lesen → 'Ana'
name.set('Maria'); // direktes Schreiben
name.update((n) => n.toUpperCase()); // Schreiben auf Basis des alten Werts
const greeting: Signal<string> = computed(() => `Hallo, ${name()}!`);
greeting(); // 'Hallo, MARIA!'
// greeting.set(...) ✗ ein computed ist schreibgeschützt
const readonlyName: Signal<string> = name.asReadonly(); // ohne Schreibzugriff freigegebendouble()= 0parity()= geradecomputedneu berechnet: 1 Mal (nur wenn sich count geändert hat)
Was du über computed() wissen solltest
- Lazy: Es wird erst berechnet, wenn es jemand liest.
- Memoisiert: Haben sich die Abhängigkeiten nicht geändert, liefert es den gecachten Wert — egal wie oft du es liest.
- Dynamische Abhängigkeiten: Es verfolgt nur die Signals, die beim letzten Durchlauf tatsächlich gelesen wurden. Bei
cond() ? a() : b()werden Änderungen anbignoriert, solangecondtrue ist. - Pur: keine Seiteneffekte (kein HTTP, kein
set()auf anderen Signals, kein DOM).
Die goldene Regel: abgeleiteter Zustand = computed()
Jeder Wert, der sich aus anderem Zustand berechnen lässt, wird nicht separat gespeichert. Die Summe eines Warenkorbs, die gefilterte Liste, „ist das Formular gültig“, „hat der Benutzer Admin-Rechte“ — das alles sind computed(). Eine einzige Quelle der Wahrheit, keine manuelle Synchronisierung.
interface CartItem {
id: number;
name: string;
price: number;
qty: number;
}
export class Cart {
protected readonly items = signal<CartItem[]>([]);
protected readonly filter = signal('');
// ✓ Alles, was sich aus etwas anderem berechnen lässt = computed()
protected readonly visible = computed(() =>
this.items().filter((i) => i.name.toLowerCase().includes(this.filter().toLowerCase())),
);
protected readonly subtotal = computed(() =>
this.items().reduce((sum, i) => sum + i.price * i.qty, 0),
);
protected readonly vat = computed(() => Math.round(this.subtotal() * 0.19));
protected readonly total = computed(() => this.subtotal() + this.vat());
protected readonly freeShipping = computed(() => this.subtotal() >= 1000);
protected changeQty(id: number, delta: number): void {
this.items.update((items) =>
items.map((i) => (i.id === id ? { ...i, qty: Math.max(0, i.qty + delta) } : i)),
);
}
}| Produkt | Preis | Menge | Total |
|---|---|---|---|
| Mechanische Tastatur | 450 RON | 1 | 450 RON |
| Ergonomische Maus | 220 RON | 0 | 0 RON |
| Monitor 27" | 1300 RON | 0 | 0 RON |
| USB-C-Hub | 160 RON | 2 | 320 RON |
// ✓ Eine einzige Quelle der Wahrheit, der Rest wird abgeleitet
export class Cart {
readonly items = signal<CartItem[]>([]);
readonly total = computed(() => this.items().reduce((s, i) => s + i.price, 0));
add(item: CartItem) {
this.items.update((list) => [...list, item]);
}
remove(id: number) {
this.items.update((list) => list.filter((i) => i.id !== id));
}
}// ✗ Abgeleiteter Zustand wird GESPEICHERT und von Hand synchronisiert
export class Cart {
items = signal<CartItem[]>([]);
total = signal(0);
add(item: CartItem) {
this.items.update((list) => [...list, item]);
this.total.set(this.total() + item.price); // leicht vergessen, leicht nicht mehr synchron
}
remove(id: number) {
this.items.update((list) => list.filter((i) => i.id !== id));
// Bug: Wir haben vergessen, total zu aktualisieren
}
}
// ✗ Genauso schlecht: ein effect, der einen Wert in ein anderes Signal „kopiert“
effect(() => this.total.set(this.items().reduce((s, i) => s + i.price, 0)));Immutability: warum push() nicht funktioniert
Ein Signal entscheidet, ob es sich geändert hat, indem es den alten und den neuen Wert mit Object.is vergleicht, also per Referenz. Wenn du ein Array oder ein Objekt an Ort und Stelle veränderst, bleibt die Referenz gleich und niemand wird benachrichtigt. Das Template rendert vielleicht zufällig neu (zum Beispiel weil du in der Komponente irgendwo geklickt hast), aber die computed()s behalten den alten Wert. Das Ergebnis: eine teilweise aktualisierte UI — der am schwersten zu findende Bug.
tags())count = computed(() => tags().length)const tags = signal(['a', 'b']);
// ✗ Mutation: dieselbe Array-Referenz → Angular erfährt NICHTS
tags().push('c');
tags.set(tags()); // hilft trotzdem nicht: Object.is(alt, neu) === true
// ✓ Immer eine neue Referenz
tags.update((t) => [...t, 'c']); // hinzufügen
tags.update((t) => t.filter((x) => x !== 'a')); // entfernen
tags.update((t) => t.map((x) => (x === 'b' ? 'B' : x))); // ändern
const user = signal({ name: 'Ana', address: { city: 'Cluj' } });
// ✗ user().address.city = 'Iași';
// ✓
user.update((u) => ({ ...u, address: { ...u.address, city: 'Iași' } }));Eigene Gleichheit mit equal
Manchmal bekommst du ein neues Objekt, das logisch „dasselbe“ ist (zum Beispiel von einer API oder aus einem Formular). Mit der Option equal vermeidest du unnötige Benachrichtigungen und Neuberechnungen.
import { signal } from '@angular/core';
interface Filters {
query: string;
page: number;
}
// Standardmäßig: Gleichheit über Object.is (Referenz).
// Mit `equal` entscheidest du, wann zwei Werte „gleich“ sind → keine sinnlosen Benachrichtigungen.
const filters = signal<Filters>(
{ query: '', page: 1 },
{ equal: (a, b) => a.query === b.query && a.page === b.page },
);
filters.set({ query: '', page: 1 }); // neues Objekt, aber „gleich“ → NIEMAND wird benachrichtigt
// Funktioniert auch bei computed:
const ids = computed(() => users().map((u) => u.id), {
equal: (a, b) => a.length === b.length && a.every((id, i) => id === b[i]),
});{ query: 'angular', page: 1 }signal(filters)1Ladevorgänge der Liste
- Ladevorgang #1 · Seite 1
signal(filters, { equal })1Ladevorgänge der Liste
- Ladevorgang #1 · Seite 1
untracked(): Lesen ohne Abhängigkeit
Innerhalb eines computed oder effect wird jedes gelesene Signal zur Abhängigkeit. Wenn du nur den aktuellen Wert brauchst, ohne bei dessen Änderung erneut zu laufen, verwende untracked().
import { effect, signal, untracked } from '@angular/core';
const user = signal('Ana');
const theme = signal('dark');
effect(() => {
// Abhängigkeit: user — der Effect läuft erneut, wenn sich user ändert
const name = user();
// Lesen OHNE Abhängigkeit: Eine Änderung des Themes führt den Effect NICHT erneut aus
const currentTheme = untracked(theme);
console.log(`[analytics] ${name} hat die Seite geöffnet (Theme: ${currentTheme})`);
});
// untracked akzeptiert auch eine Funktion — praktisch, wenn du Code aufrufst, der intern Signals liest
effect(() => {
const id = selectedId();
untracked(() => this.logger.log(`Ausgewählt: ${id}`)); // der Logger darf beliebige Signals lesen
});const t = theme();Läufe: 1- Ana hat die Seite geöffnet (Theme: dark)
const t = untracked(theme);Läufe: 1- Ana hat die Seite geöffnet (Theme: dark)
Signals in Services: privat schreiben, öffentlich lesen
Das Grundmuster für jeden geteilten Zustand: Das beschreibbare Signal ist private, nach außen gibst du asReadonly() und computed()s frei, und Änderungen laufen über Methoden mit klaren Namen. Alle Details findest du auf der Seite State Management.
import { Service, computed, signal } from '@angular/core';
@Service()
export class CounterStore {
// Schreiben ist privat: Nur der Store ändert seinen Zustand …
private readonly _count = signal(0);
// … und nach außen gibst du nur das Lesen frei.
readonly count = this._count.asReadonly();
readonly isZero = computed(() => this._count() === 0);
increment(): void {
this._count.update((c) => c + 1);
}
}Wann ja, wann nein
- Der Zustand einer Komponente (Filter, Auswahl, Umschalter, die aktuelle Seite).
- Geteilter Zustand in einem Service / Store.
- Jeder Wert, der im Template angezeigt wird.
- Abgeleitete Werte: Summen, gefilterte Listen, Flags (
computed). - Inputs von Komponenten (
input()ist ebenfalls ein Signal).
- Ströme von Events über die Zeit (WebSocket, Drag, Tippen mit komplexen Operatoren) — dort ist RxJS ausdrucksstärker. Siehe Signals ↔ RxJS.
- Asynchrone Daten vom Server — nutze resource / httpResource statt eines Signals, das du per Hand aus einem
subscribesetzt. - Konstanten, die sich nie ändern — ein einfaches
readonlyreicht. - „Editierbarer“ abgeleiteter Zustand (eine Auswahl, die sich zurücksetzt) — nutze linkedSignal.
Häufige Stolperfallen
- Du vergisst die Klammern:
{{ count }}zeigt die Funktion an, nicht den Wert. Richtig:{{ count() }}. - Du mutierst, statt zu ersetzen:
push,splice,obj.x = 1benachrichtigen nicht. - Seiteneffekte in computed: Ein
computed, das einen HTTP-Call macht oder ein anderes Signal setzt, ist ein Bug, der nur darauf wartet, aufzutreten. - Async in computed:
computed(async () => ...)liefert ein Promise, nicht den Wert. Verwenderesource(). - Du liest nur einmal:
const x = this.count();im Konstruktor ist eine Momentaufnahme, keine reaktive Verbindung. - Synchronisieren mit effect:
effect(() => b.set(a() * 2))→ verwendecomputed(oderlinkedSignal, wenn b auch beschreibbar sein muss).
Spickzettel
// ERSTELLEN
const a = signal(0); // WritableSignal<number>
const b = signal<User | null>(null); // expliziter Typ, wenn du mit null startest
const c = computed(() => a() * 2); // Signal<number>, schreibgeschützt, lazy, memoisiert
// LESEN
a(); // in TS und im Template: {{ a() }}
// SCHREIBEN
a.set(5);
a.update((v) => v + 1);
// SICHERE FREIGABE
readonly value = this._value.asReadonly();
// OPTIONEN
signal(x, { equal: (p, n) => p.id === n.id, debugName: 'selected' });
// LESEN OHNE ABHÄNGIGKEIT
untracked(a); untracked(() => a() + b());
// PRÜFUNGEN
isSignal(x); isWritableSignal(x);