linkedSignal ist die Antwort auf ein wiederkehrendes State-Muster: Du hast einen Wert, der aus einer Quelle stammt — Server-Default, aktuell ausgewählter Datensatz, ein Eltern-Signal — den der User aber lokal überschreiben darf. Bei einem normalen signal() müsstest du den Reset von Hand verkabeln; mit computed() wäre er nicht schreibbar; mit effect({ allowSignalWrites: true }) würdest du ein Side-Effect-Pattern bauen, das offiziell als Anti-Pattern gilt.
Genau dafür gibt es seit Angular 19 (stabilisiert in 20/21) linkedSignal. Es ist ein schreibbares Signal, dessen Default-Wert reaktiv aus einer Quelle berechnet wird — bei jeder Quell-Änderung wird der Wert neu gesetzt, dazwischen darf der User schreiben, was er will. Dieser Artikel zeigt Syntax, Edge-Cases, das prev-Argument für Smart-Reset-Strategien und einen kompletten Vergleich zu computed und effect.
Schreibbar plus reaktiver Reset
Ein normales signal() ist schreibbar, kennt aber nichts außer seinem letzten gesetzten Wert. Ein computed() ist reaktiv, aber nicht schreibbar — du kannst keinen User-Wert hineinlegen. Wenn du beides willst — Reaktivität auf eine Quelle und Schreibbarkeit für den User — landest du klassisch bei einem signal() plus einem effect({ allowSignalWrites: true }). Genau dieses Pattern ist das, was linkedSignal ersetzt.
linkedSignal(() => quelle()) erzeugt einen WritableSignal<T>. Der Initialwert wird beim ersten Read berechnet, indem die Computation-Funktion läuft. Ändert sich eine darin gelesene Quelle, wird die Computation erneut ausgeführt — und ihr Resultat überschreibt den aktuellen Wert. Zwischen Quell-Updates darf der User mit .set() und .update() einen beliebigen anderen Wert ablegen.
Das Profil-Form-Problem
Stell dir ein User-Profil vor: Beim Laden kommt ein Default vom Server (Anzeigename, Theme, Sprache). Der User darf editieren — aber wenn der Server neu lädt (Tab-Wechsel, Refresh, anderer Datensatz), sollen die Server-Defaults wieder greifen. Mit signal() müsstest du beim Server-Update manuell den Wert zurücksetzen. Mit linkedSignal passiert das automatisch.
import { Component, inject, linkedSignal, signal } from '@angular/core';
interface ProfileDto {
displayName: string;
theme: 'light' | 'dark';
language: 'de' | 'en';
}
@Component({ selector: 'app-profile-form', standalone: true, template: `
<label>Anzeigename
<input [value]="displayName()"
(input)="displayName.set($any($event.target).value)" />
</label>
<label>Theme
<select [value]="theme()"
(change)="theme.set($any($event.target).value)">
<option value="light">Hell</option>
<option value="dark">Dunkel</option>
</select>
</label>
<button (click)="save()">Speichern</button>
` })
export class ProfileFormComponent {
// Server-State: kommt z. B. aus einem Resource oder HTTP-Service
serverProfile = signal<ProfileDto>({
displayName: 'Max',
theme: 'light',
language: 'de',
});
// Lokaler Edit-State, der bei Server-Update neu seeded wird
displayName = linkedSignal(() => this.serverProfile().displayName);
theme = linkedSignal(() => this.serverProfile().theme);
save() {
// Sende this.displayName() etc. an den Server
}
}Sobald serverProfile.set(...) einen neuen Wert bekommt — z. B. nach einem Reload — werden displayName und theme automatisch zurückgesetzt. Eingaben des Users zwischen zwei Server-Updates bleiben erhalten.
Zwei Formen: kurz und ausführlich
linkedSignal hat zwei Overloads. Die Kurzform nimmt nur eine Computation-Funktion und ist die häufigere:
import { linkedSignal, signal } from '@angular/core';
const userId = signal('u-1');
// Kurzform: linkedSignal(() => computation)
const draftName = linkedSignal(() => `Entwurf von ${userId()}`);
draftName(); // 'Entwurf von u-1'
draftName.set('Mein Name'); // User schreibt
draftName(); // 'Mein Name'
userId.set('u-2'); // Quelle aendert sich
draftName(); // 'Entwurf von u-2' (Reset!)Die Object-Form trennt source und computation — das ist nötig, wenn du im Computation-Schritt auf den vorherigen Wert zugreifen willst:
import { linkedSignal, signal } from '@angular/core';
const list = signal<string[]>(['a', 'b', 'c']);
// Object-Form: source + computation mit Zugriff auf prev
const selected = linkedSignal<string[], string>({
source: () => list(),
computation: (newList, prev) => {
// Wenn der vorherige Wert noch in der neuen Liste enthalten ist: behalten
if (prev && newList.includes(prev.value)) return prev.value;
return newList[0];
},
});
selected(); // 'a'
selected.set('b');
list.set(['a', 'b', 'c', 'd']);
selected(); // 'b' (war noch enthalten -> behalten)
list.set(['x', 'y', 'z']);
selected(); // 'x' (war nicht mehr enthalten -> erster Eintrag)Smart-Reset statt Hard-Reset
Das prev-Argument der Object-Form ist der Schlüssel zu eleganten Reset-Strategien. Es enthält den vorherigen source-Wert und den vorherigen value. Damit kannst du Logik bauen, die sagt: „Wenn der neue Source-Zustand mit dem alten User-Wert noch kompatibel ist, behalte ihn — sonst neu seeden.”
Typische Anwendungsfälle:
- Selektion in einer Liste behalten, solange das Element noch existiert.
- Filter-Wert behalten, solange er noch im erlaubten Wertebereich liegt.
- Page-Number auf 1 zurücksetzen, sobald die Daten-Quelle sich grundlegend ändert.
import { linkedSignal, signal } from '@angular/core';
interface Page<T> { items: T[]; page: number; total: number; }
const data = signal<Page<string>>({ items: [], page: 1, total: 0 });
// Page-Number bleibt gleich, solange die Total-Anzahl noch genug Seiten hergibt
const currentPage = linkedSignal<Page<string>, number>({
source: () => data(),
computation: (newData, prev) => {
const maxPage = Math.max(1, Math.ceil(newData.total / 20));
if (prev && prev.value <= maxPage) return prev.value;
return 1;
},
});Custom-Equality wie bei signal()
Wie signal() und computed() akzeptiert auch linkedSignal eine equal-Funktion, mit der du steuern kannst, wann eine Änderung als „echt” gilt. Das ist wichtig, wenn deine Computation Objekte zurückgibt — sonst feuert das Signal bei jedem Source-Update, auch wenn der Inhalt identisch ist.
import { linkedSignal, signal } from '@angular/core';
interface Settings { theme: 'light' | 'dark'; lang: 'de' | 'en'; }
const server = signal<Settings>({ theme: 'light', lang: 'de' });
const settings = linkedSignal(
() => ({ ...server() }),
{ equal: (a, b) => a.theme === b.theme && a.lang === b.lang },
);
// Object-Form: equal als Schluessel im Options-Objekt
const settings2 = linkedSignal({
source: () => server(),
computation: s => ({ ...s }),
equal: (a, b) => a.theme === b.theme && a.lang === b.lang,
});Subscriber des linkedSignal (Templates, abhängige computed, effect) feuern jetzt nur dann, wenn equal false zurückgibt — nicht bei jedem Object-Recompute.
Drei Wege, ein Problem
| Pattern | Schreibbar | Reaktiv auf Source | Idiomatisch | Wofür |
|---|---|---|---|---|
signal() allein | Ja | Nein | Ja | Reiner lokaler State |
computed() | Nein | Ja | Ja | Reiner abgeleiteter Wert |
linkedSignal() | Ja | Ja | Ja | Schreibbarer Wert mit Auto-Reset |
signal() + effect({ allowSignalWrites }) | Ja | Ja | Anti-Pattern | (alt) Sync zwischen Signals |
Das letzte Pattern — Signal + Effect — funktioniert technisch noch, ist aber offiziell als „benutze besser linkedSignal” markiert. Effects sollen Side-Effects (DOM, Logging, Analytics) ausführen, keine State-Synchronisation.
import { computed, effect, linkedSignal, signal } from '@angular/core';
const source = signal({ name: 'Anna', age: 30 });
// 1) computed: Read-only, perfekt fuer reine Ableitungen
const greeting = computed(() => `Hallo ${source().name}`);
// 2) signal + effect: ANTI-PATTERN fuer State-Sync
const draftBad = signal(source().name);
// (war frueher allowSignalWrites: true im Effect-Options)
// 3) linkedSignal: das richtige Werkzeug fuer „Reset bei Source"
const draftGood = linkedSignal(() => source().name);
draftGood.set('Bea'); // User-Override
source.set({ name: 'Carl', age: 31 }); // -> draftGood() ist 'Carl'Eine Tabelle mit Filter, der bei neuer Daten-Quelle smart resettet
import { Component, computed, linkedSignal, signal } from '@angular/core';
interface Row { id: string; status: 'open' | 'done' | 'archived'; label: string; }
@Component({ selector: 'app-filterable-table', standalone: true, template: `
<header>
<button (click)="loadOpen()">Offene laden</button>
<button (click)="loadAll()">Alle laden</button>
<select [value]="filter()" (change)="filter.set($any($event.target).value)">
<option value="all">Alle</option>
<option value="open">Offen</option>
<option value="done">Erledigt</option>
<option value="archived">Archiviert</option>
</select>
</header>
<ul>
@for (r of visible(); track r.id) {
<li>{{ r.label }} <small>({{ r.status }})</small></li>
} @empty { <li class="muted">Keine Zeilen</li> }
</ul>
` })
export class FilterableTableComponent {
rows = signal<Row[]>([]);
// Smart-Reset: behalte den Filter, wenn er noch zur neuen Daten-Menge passt
filter = linkedSignal<Row[], 'all' | Row['status']>({
source: () => this.rows(),
computation: (newRows, prev) => {
if (!prev) return 'all';
if (prev.value === 'all') return 'all';
const stillExists = newRows.some(r => r.status === prev.value);
return stillExists ? prev.value : 'all';
},
});
visible = computed(() => {
const f = this.filter();
return f === 'all' ? this.rows() : this.rows().filter(r => r.status === f);
});
loadOpen() {
this.rows.set([
{ id: '1', status: 'open', label: 'Aufgabe A' },
{ id: '2', status: 'open', label: 'Aufgabe B' },
]);
}
loadAll() {
this.rows.set([
{ id: '1', status: 'open', label: 'Aufgabe A' },
{ id: '3', status: 'done', label: 'Aufgabe C' },
{ id: '4', status: 'archived', label: 'Alt' },
]);
}
}Sequenz: User wählt „Erledigt” — Filter zeigt nichts (es gibt nur „open”). User klickt „Alle laden” — Filter bleibt „Erledigt”, weil ein Eintrag mit Status done existiert. User klickt „Offene laden” — Filter springt automatisch zurück auf „all”, weil kein Eintrag mit done mehr da ist.
Besonderheiten
linkedSignal ist Writable
.set(), .update() und .asReadonly() funktionieren genau wie bei signal(). Solange die Quelle stabil bleibt, ist es ein normales Signal. Erst wenn die Quelle neu emittiert, wird der Wert wieder durch die Computation überschrieben — User-Schreibungen dazwischen bleiben erhalten.
Bei Source-Update wird der Wert NEU berechnet
Das ist Feature, nicht Bug — und der Hauptunterschied zu signal(). Wenn du das nicht willst, ist linkedSignal das falsche Werkzeug. User-Overrides werden bei Source-Wechsel verworfen, es sei denn, du nutzt die Object-Form mit prev-Logik.
Object-Form mit prev erlaubt Smart-Reset
computation: (newSrc, prev) => … bekommt den vorherigen { source, value }. Damit kannst du Patterns bauen wie „Behalte die Selektion, solange sie noch in der neuen Liste enthalten ist” — der Klassiker für ListenView- und Tab-Komponenten.
Die richtige Wahl gegen effect+writable
Für State-Synchronisation zwischen Signals ist linkedSignal die idiomatische Lösung — das alte Pattern effect({ allowSignalWrites: true }, () => target.set(source())) ist offiziell als Anti-Pattern markiert. Effects sind für Side-Effects, nicht für Sync.
Verkettung mit computed funktioniert nahtlos
computed(() => myLinked() * 2) registriert sich auf den linkedSignal-Wert wie auf jedes andere Signal. Du kannst einen linkedSignal als Eingang in beliebige computed-Ketten setzen — die Reaktivität propagiert korrekt.
Source-Funktion darf weitere Signals lesen
In () => sourceA() + sourceB() werden beide Signals automatisch tracked. Eine Änderung in einem von beiden triggert die Computation neu. Das macht linkedSignal zu einem leichten Werkzeug für „mehrere Eingänge, ein schreibbarer Wert”.
equal sparsam einsetzen, aber bei Objekten oft nötig
Wenn deine Computation neue Objekt-Referenzen erzeugt (Spread, map, filter), feuern Subscriber bei jedem Source-Tick — auch wenn der Inhalt gleich ist. Eine custom equal-Funktion kostet wenig Code und vermeidet unnötige Re-Renders im Template.
Weiterführende Ressourcen
Externe Quellen
- linkedSignal API – Angular.dev
- Signals Guide – Angular.dev
- Computed Signals – Angular.dev
- Effects Guide – Angular.dev