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.

TypeScript writable-signal.interface.ts
// 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>
TypeScript basics.ts
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());      // 6

Vollstä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().

TypeScript set-detail.ts
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.

TypeScript set-vs-update.ts
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.

TypeScript equality-falle.ts
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.

TypeScript custom-equality.ts
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.

TypeScript immutability-pattern.ts
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.

TypeScript naming-convention.ts
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 }).

TypeScript writable-in-effect.ts
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.

TypeScript todo-list.component.ts
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([]);
    }
}
Aspektsignal<T>BehaviorSubject<T>Plain Property
Lese-Aufrufs()subj.value oder subscribedirekter Zugriff
Schreibens.set(v) / s.update(fn)subj.next(v)this.x = v
Equality-VergleichObject.is (konfigurierbar)keinerkeiner
Subscribe nötig?NeinJaNein
OnPush-kompatibelJa, automatischMit async-PipeNein, nur per markForCheck
Memory-Leak-RisikoKeinesBei vergessener UnsubscriptionKeines
Async-OpsÜber rxjs-interopErstklassig (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

/ Weiter

Zurück zu Signals

Zur Übersicht