Writable Signals sind die schreibbaren Quellen im reaktiven Graph. Du erzeugst sie mit signal(initial), liest sie als Funktion (mySignal()) und änderst sie über .set() oder .update(). Klingt trivial — ist aber voller subtiler Mechaniken: Equality-Vergleich, Immutability, Custom-Equality-Funktionen, asReadonly() für saubere Service-APIs.
Versions-Baseline ist Angular 21 (Nov 2025). Signals sind seit v17 stable; WritableSignal ist die zentrale Schnittstelle, auf die alle Mutations-Operationen abgebildet werden. In v22 wird OnPush der Default — Komponenten, die ausschließlich auf Writable Signals setzen, profitieren am meisten.
Definition und Schnittstelle
Ein Writable Signal ist ein Signal, dessen Wert nach der Erzeugung verändert werden kann. Die Factory-Funktion signal<T>(initial: T) gibt ein WritableSignal<T> zurück, das die Schreibmethoden .set() und .update() plus die Konvertierung .asReadonly() mitbringt.
// Vereinfachte Version der offiziellen Angular-Schnittstelle
interface Signal<T> {
(): T; // Lese-Aufruf
}
interface WritableSignal<T> extends Signal<T> {
set(value: T): void; // Wert ersetzen
update(updateFn: (value: T) => T): void; // Wert transformieren
asReadonly(): Signal<T>; // Read-only-Sicht
}
// Erzeugung:
// signal<T>(initialValue: T, options?: CreateSignalOptions<T>): WritableSignal<T>import { signal } from '@angular/core';
const count = signal(0);
console.log(count()); // 0 — Lese-Aufruf
count.set(5); // direkt setzen
console.log(count()); // 5
count.update((v) => v + 1);// transformieren
console.log(count()); // 6Vollständiger Wert-Replace
.set(value) ersetzt den aktuellen Wert vollständig durch den übergebenen. Der Aufruf ist synchron — der nächste Lese-Aufruf liefert sofort den neuen Wert. Wenn der neue Wert laut Equality-Vergleich identisch zum alten ist, passiert nichts: Keine Benachrichtigung, kein Re-Render. Default-Equality ist Object.is().
import { Component, signal } from '@angular/core';
@Component({
selector: 'app-user-card',
standalone: true,
template: `
<p>{{ user().name }} — {{ user().age }} Jahre</p>
<button (click)="age25()">Auf 25 setzen</button>
<button (click)="rename()">Umbenennen</button>
`,
})
export class UserCardComponent {
readonly user = signal({ name: 'Ada', age: 36 });
age25() {
// Vollständiger Replace mit neuem Object — neue Reference, Update wird gefeuert
this.user.set({ ...this.user(), age: 25 });
}
rename() {
// Identische Reference, aber wir wollen einen neuen Wert — also kopieren
this.user.set({ ...this.user(), name: 'Grace' });
}
}set() ist die richtige Wahl, wenn du den neuen Wert bereits unabhängig vom alten kennst — etwa nach einem HTTP-Response, beim Reset, oder wenn ein Form-Submit den State komplett überschreibt.
Inkrementelle Transformation
.update(fn) nimmt eine Funktion, die den alten Wert bekommt und den neuen zurückgibt. Idiomatisch ist das immer dort, wo der neue Wert vom alten abhängt: Counter, Toggle, Anhängen an eine Liste.
import { Component, signal } from '@angular/core';
@Component({
selector: 'app-counter',
standalone: true,
template: `
<p>Wert: {{ count() }}</p>
<button (click)="incSet()">+1 (set)</button>
<button (click)="incUpdate()">+1 (update)</button>
<button (click)="toggle()">Toggle</button>
`,
})
export class CounterComponent {
readonly count = signal(0);
readonly active = signal(false);
// Variante 1: set() — alten Wert manuell lesen
incSet() {
this.count.set(this.count() + 1);
}
// Variante 2: update() — Funktion bekommt den alten Wert, gibt neuen zurück
incUpdate() {
this.count.update((c) => c + 1);
}
// Klassischer Toggle: per update() einfach !old
toggle() {
this.active.update((a) => !a);
}
}Beide Varianten sind funktional äquivalent, aber update() ist sicherer in Concurrency-Szenarien (z. B. Effects, die andere Signals lesen) und drückt die Intention klarer aus: „nimm den alten Wert und mach was draus”.
Default ist Object.is() — und das hat Konsequenzen
Wenn du ein Signal schreibst, vergleicht Angular den neuen Wert mit dem alten via Object.is(). Sind sie laut diesem Vergleich gleich, passiert nichts: Kein Update wird emittiert, kein Re-Render läuft. Bei Primitives (Number, String, Boolean) ist das genau das, was man erwartet. Bei Objects und Arrays aber schlägt eine bekannte JavaScript-Falle zu: Object.is(arr, arr) === true, auch wenn du das Array intern mit .push() mutiert hast.
import { signal } from '@angular/core';
const list = signal<number[]>([]);
// FALSCH: Array in Place mutieren — Reference bleibt gleich, kein Update
list().push(1);
list.set(list()); // Object.is(arr, arr) === true → kein Re-Render!
// RICHTIG: neue Reference erzeugen
list.update((arr) => [...arr, 1]);
// Bei Objects analog:
const user = signal({ name: 'Ada', tags: ['math'] });
// FALSCH:
user().tags.push('history'); // Mutation, gleiche Reference
user.set(user()); // Kein Update!
// RICHTIG:
user.update((u) => ({ ...u, tags: [...u.tags, 'history'] }));equal-Option für semantischen Vergleich
Manchmal willst du ein Signal nicht bei jeder neuen Reference, sondern nur bei semantisch verändertem Wert updaten. Klassisches Beispiel: Ein API liefert Daten, die du periodisch neu lädst — Reference ist neu, Inhalt ist identisch. Ohne Custom Equality rendert die UI bei jedem Reload, obwohl sich nichts geändert hat.
import { signal } from '@angular/core';
interface User {
id: string;
name: string;
email: string;
}
// Eigene Deep-Equal-Funktion (vereinfacht — in Produktion lodash.isEqual o. ä.)
const userEqual = (a: User, b: User): boolean =>
a.id === b.id && a.name === b.name && a.email === b.email;
const currentUser = signal<User>(
{ id: 'u-1', name: 'Ada', email: 'ada@example.com' },
{ equal: userEqual }
);
// Neue Reference, aber semantisch identisch — KEIN Update
currentUser.set({ id: 'u-1', name: 'Ada', email: 'ada@example.com' });
// Echte Änderung — Update wird gefeuert
currentUser.set({ id: 'u-1', name: 'Ada', email: 'ada@new.com' });Spread, structuredClone, Immer
Die Signal-Welt ist eine immutable Welt: Jede Änderung ist ein neuer Wert. JavaScript bietet dafür drei pragmatische Werkzeuge: den Spread-Operator (flach), structuredClone() (tief, Browser-nativ seit 2022), und externe Libraries wie Immer, die Mutations-Syntax in Immutable-Updates übersetzen.
import { signal } from '@angular/core';
interface State {
user: { name: string; tags: string[] };
settings: { theme: 'light' | 'dark' };
}
const state = signal<State>({
user: { name: 'Ada', tags: ['math'] },
settings: { theme: 'light' },
});
// 1. Flacher Spread — reicht für Top-Level-Updates
state.update((s) => ({ ...s, settings: { ...s.settings, theme: 'dark' } }));
// 2. Verschachtelt: jede Ebene neu spreaden
state.update((s) => ({
...s,
user: { ...s.user, tags: [...s.user.tags, 'history'] },
}));
// 3. structuredClone — tiefe Kopie, dann gezielt anpassen
state.update((s) => {
const next = structuredClone(s);
next.user.tags.push('cs');
return next;
});Bei kleinen Strukturen ist der Spread-Operator am performantesten und am lesbarsten. Bei großen, tief verschachtelten States lohnt sich Immer — es liefert eine Mutations-API, die intern eine immutable Kopie erzeugt, und macht den Code drastisch lesbarer.
readonly und Naming
In einer Component- oder Service-Klasse ist die idiomatische Form: readonly mySignal = signal(initial). Das readonly betrifft das Property — niemand kann das Signal-Objekt durch ein anderes ersetzen. Der Wert hinter dem Signal bleibt schreibbar.
import { Injectable, signal } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class CartService {
// GUT: readonly + kurze, beschreibende Namen, KEIN Prefix
readonly items = signal<CartItem[]>([]);
readonly isCheckingOut = signal(false);
// VERMEIDEN: Prefix wie $ oder Suffix wie Signal/Sig
// readonly $items = signal<CartItem[]>([]);
// readonly itemsSignal = signal<CartItem[]>([]);
add(item: CartItem) {
this.items.update((list) => [...list, item]);
}
remove(id: string) {
this.items.update((list) => list.filter((i) => i.id !== id));
}
}
interface CartItem {
id: string;
name: string;
price: number;
}Beziehung zu computed und effect
Ein WritableSignal ist die Quelle. Es speist computed()-Ableitungen (Read-only) und triggert effect()-Side-Effects. Schreibst du in einem effect() zurück auf ein Signal, riskierst du eine Endlos-Schleife — Angular schützt davor mit einem expliziten Opt-in: effect(fn, { allowSignalWrites: true }).
import { Component, effect, signal } from '@angular/core';
@Component({
selector: 'app-search',
standalone: true,
template: `
<input [value]="query()" (input)="query.set($any($event.target).value)" />
<p>Aktive Suche: {{ activeQuery() }}</p>
`,
})
export class SearchComponent {
readonly query = signal('');
readonly activeQuery = signal('');
constructor() {
// Debounce per setTimeout: writeback in einen Effect → allowSignalWrites
let timer: ReturnType<typeof setTimeout> | null = null;
effect(
() => {
const q = this.query();
if (timer) clearTimeout(timer);
timer = setTimeout(() => this.activeQuery.set(q), 250);
},
{ allowSignalWrites: true }
);
}
}Mehr zur Mechanik liest du in Effect — reaktive Side-Effects. Die Kurzfassung: Writebacks im Effect sind möglich, aber selten die richtige Antwort — meistens ist computed() oder linkedSignal() der bessere Weg.
Vollständige Komponente
Eine TODO-Liste ist der ideale Showcase: Sie nutzt einen Array-State, kombiniert set() und update(), illustriert das Immutability-Pattern und zeigt das Zusammenspiel mit @for und track.
import { ChangeDetectionStrategy, Component, computed, signal } from '@angular/core';
interface Todo {
id: string;
text: string;
done: boolean;
}
@Component({
selector: 'app-todo-list',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="todos">
<header>
<input
#input
placeholder="Neues Todo…"
(keydown.enter)="add(input.value); input.value = ''"
/>
<p class="meta">
Offen: {{ openCount() }} / Gesamt: {{ todos().length }}
</p>
</header>
<ul>
@for (todo of todos(); track todo.id) {
<li [class.done]="todo.done">
<label>
<input
type="checkbox"
[checked]="todo.done"
(change)="toggle(todo.id)"
/>
{{ todo.text }}
</label>
<button (click)="remove(todo.id)">×</button>
</li>
} @empty {
<li class="empty">Noch nichts zu tun.</li>
}
</ul>
<footer>
<button (click)="clearDone()">Erledigte löschen</button>
<button (click)="reset()">Alles löschen</button>
</footer>
</section>
`,
})
export class TodoListComponent {
readonly todos = signal<Todo[]>([]);
readonly openCount = computed(
() => this.todos().filter((t) => !t.done).length
);
add(text: string) {
const trimmed = text.trim();
if (!trimmed) return;
this.todos.update((list) => [
...list,
{ id: crypto.randomUUID(), text: trimmed, done: false },
]);
}
toggle(id: string) {
this.todos.update((list) =>
list.map((t) => (t.id === id ? { ...t, done: !t.done } : t))
);
}
remove(id: string) {
this.todos.update((list) => list.filter((t) => t.id !== id));
}
clearDone() {
this.todos.update((list) => list.filter((t) => !t.done));
}
reset() {
this.todos.set([]);
}
}| Aspekt | signal<T> | BehaviorSubject<T> | Plain Property |
|---|---|---|---|
| Lese-Aufruf | s() | subj.value oder subscribe | direkter Zugriff |
| Schreiben | s.set(v) / s.update(fn) | subj.next(v) | this.x = v |
| Equality-Vergleich | Object.is (konfigurierbar) | keiner | keiner |
| Subscribe nötig? | Nein | Ja | Nein |
| OnPush-kompatibel | Ja, automatisch | Mit async-Pipe | Nein, nur per markForCheck |
| Memory-Leak-Risiko | Keines | Bei vergessener Unsubscription | Keines |
| Async-Ops | Über rxjs-interop | Erstklassig (Operatoren) | Manuell |
Wissenswertes über Writable Signals
set() und update() sind synchron
Direkt nach dem Aufruf gibt der nächste Lese-Aufruf bereits den neuen Wert. Es gibt keinen Microtask-Delay wie bei Promise.resolve().then(...) und keinen async Subscribe wie bei Observables. Dieses Verhalten ist deterministisch und macht Tests einfach.
Mehrere Schreibvorgänge werden gebatcht
Schreibst du innerhalb desselben Microtasks mehrfach auf dasselbe Signal (oder auf verschiedene Signals derselben Komponente), führt Angular trotzdem nur einen Re-Render durch. Du musst nicht selbst batchen — das System macht es für dich.
asReadonly() für saubere Service-APIs
WritableSignal hat eine Methode .asReadonly(), die eine Read-only-Sicht zurückgibt. Idiomatisch in Services: privat ein _state = signal(...) halten, öffentlich state = this._state.asReadonly() exportieren — Konsumenten können lesen, aber niemand kann von außen .set() rufen.
update() darf den alten Wert nicht mutieren
Die Update-Funktion bekommt den alten Wert und soll einen neuen zurückgeben. Mutierst du den alten Wert in Place und returnst ihn, schlägt Default-Equality zu (Object.is(old, old) === true) und nichts passiert. Immer Spread, neues Object, neue Array-Reference.
Signal-Lookup ist O(1)
Anders als ein Observable, das beim subscribe einen Callback in eine interne Liste hängt, ist ein Signal-Lese-Aufruf eine direkte Property-Lese-Operation. Auch in heißen Loops (Tausende Lese-Aufrufe pro Frame) ist das billig — kein Subscribe-Overhead, kein Operator-Stack.
Mutationen sind Anti-Pattern, auch wenn TypeScript sie zulässt
mySignal().push(...) kompiliert problemlos, ist aber falsch: Es ändert den Wert ohne das Signal zu informieren. Das Resultat: Stale UI, Effects feuern nicht, Computed-Werte sind veraltet. Trainiere dir an, die Antwort auf „wie ändere ich diesen Wert” immer mit update() und Spread zu beginnen.
Schreiben aus dem Template ist möglich, aber unsauber
Im Template kannst du (click)="counter.set(counter() + 1)" schreiben — Angular verbietet es nicht. Der bessere Stil ist trotzdem: Methode in der Klasse (inc()), im Template (click)="inc()". Das hält Geschäftslogik aus der Markup-Schicht raus und macht das Verhalten testbar.
Custom equal kostet bei jedem Schreibvorgang
Die equal-Option ist mächtig, aber nicht gratis. Bei großen, tief verschachtelten Strukturen kann der Custom-Vergleich teurer sein als der Re-Render, den er verhindert. Profile messen, dann entscheiden — Default-Equality ist erstaunlich oft genau richtig.
Weiterführende Ressourcen
Externe Quellen
- Writable Signals – Angular.dev Guide
- signal() – API Reference
- WritableSignal – API Reference
- CreateSignalOptions (equal) – API Reference