Reaktivität ohne Framework-Bindung
Signals sind ein Reaktivitäts-Muster, das in Vue, Solid, Preact und Angular unabhängig voneinander implementiert wurde, aber auf denselben wenigen Grundbausteinen beruht. Wer versteht, wie ein minimales Signals-System aus Getter, Setter und automatischem Dependency-Tracking aufgebaut ist, kann Reaktivität in jedem Vanilla-JavaScript-Projekt einsetzen, ganz ohne Framework-Abhängigkeit.
Inhaltsverzeichnis
- 1. Was Signals wirklich sind
- 2. Der Grundbaustein: ein minimales Signal
- 3. Effects: automatisches Dependency-Tracking
- 4. Computed Signals: abgeleitete Werte mit Memoization
- 5. Batching: mehrere Updates zu einem Zyklus bündeln
- 6. Cleanup und Speicherverwaltung
- 7. Signals in bestehende Frameworks integrieren
- 8. Signals gegenüber anderen Reactivity-Modellen
- 9. Signals im Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Was Signals wirklich sind
Signals sind reaktive Wertebehälter, die bei Änderung automatisch alle abhängigen Berechnungen und Effekte aktualisieren, ohne dass ein Entwickler manuell Änderungen propagieren muss. Anders als beim Virtual-DOM-Diffing, das ganze Komponentenbäume neu rendert und dann vergleicht, aktualisieren Signals nur exakt die Stellen, die tatsächlich von einem geänderten Wert abhängen, ein Prinzip, das oft als Fine-Grained Reactivity bezeichnet wird.
Vue, Solid, Preact und Angular haben Signals unabhängig voneinander implementiert, mit leicht unterschiedlicher API, aber demselben Kernmechanismus: ein Getter liest den aktuellen Wert und registriert dabei implizit den aufrufenden Kontext als Abhängigkeit, ein Setter schreibt einen neuen Wert und benachrichtigt alle registrierten Abhängigen. Wer dieses Muster einmal von Grund auf selbst implementiert hat, versteht die Reactivity-Systeme aller genannten Frameworks strukturell, unabhängig von der jeweiligen Syntax.
Dieser Artikel baut eine eigene, minimale Signals-Bibliothek von Grund auf, ohne Framework-Abhängigkeit, in reinem JavaScript. Jeder Abschnitt fügt einen Baustein hinzu: zuerst das Signal selbst, dann automatisches Dependency-Tracking über Effects, danach abgeleitete Computed-Werte, Batching für konsistente Updates, und schließlich Cleanup-Strategien gegen Speicherlecks.
2. Der Grundbaustein: ein minimales Signal
Der einfachste Baustein eines Signals-Systems ist eine Closure, die einen Wert und ein Set von Subscribern kapselt. Der Getter liefert den aktuellen Wert zurück, der Setter aktualisiert den Wert und benachrichtigt jeden registrierten Subscriber. Entscheidend ist, dass beim Lesen des Werts geprüft wird, ob gerade ein aktiver Tracking-Kontext existiert, typischerweise über eine globale Variable, die während der Ausführung eines Effects gesetzt wird.
Diese globale Tracking-Variable ist der Trick, der Signals von einfachen Getter/Setter-Paaren unterscheidet: statt dass ein Effect explizit sagt, welche Signale er beobachtet, registriert sich das Signal selbst beim aktiven Effect, sobald es gelesen wird. Diese implizite Registrierung ist der Grund, warum Signals-basierte Reactivity ohne manuelle Dependency-Arrays auskommt, ein deutlicher Unterschied zu useEffect in React, das eine explizite Dependency-Liste verlangt.
// Minimal signal implementation with implicit dependency registration
let activeSubscriber = null;
function createSignal(initialValue) {
let value = initialValue;
const subscribers = new Set();
function read() {
// Implicitly register the currently running effect as a dependent
if (activeSubscriber) {
subscribers.add(activeSubscriber);
}
return value;
}
function write(newValue) {
if (Object.is(value, newValue)) return; // skip no-op updates
value = newValue;
for (const subscriber of subscribers) {
subscriber();
}
}
return [read, write];
}
const [count, setCount] = createSignal(0);
console.log(count()); // 0
setCount(5);
console.log(count()); // 5
3. Effects: automatisches Dependency-Tracking
Ein Effect ist eine Funktion, die beim Ausführen die globale Tracking-Variable auf sich selbst setzt, sich selbst ausführt und dabei automatisch bei jedem gelesenen Signal registriert wird. Nach der Ausführung wird die Tracking-Variable zurückgesetzt. Ändert sich später eines der gelesenen Signals, ruft dessen Setter den Effect erneut auf, wodurch sich die Dependency-Liste bei jedem Lauf neu aufbaut, ein Muster, das dynamische Abhängigkeiten korrekt abbildet, etwa wenn ein Effect abhängig von einer Bedingung mal Signal A und mal Signal B liest.
Diese dynamische Neuregistrierung bei jedem Lauf ist ein wesentlicher Vorteil gegenüber statischen Dependency-Arrays: ein Signals-Effect muss nie manuell gepflegt werden, wenn sich die gelesenen Werte je nach Codepfad ändern. Das Fehlerbild einer vergessenen Dependency, ein bekanntes Problem bei useEffect in React, existiert bei Signals strukturell nicht, weil die Abhängigkeiten zur Laufzeit aus dem tatsächlichen Leseverhalten abgeleitet werden.
// Effect with automatic re-tracking on every run
function createEffect(fn) {
function execute() {
activeSubscriber = execute;
try {
fn();
} finally {
activeSubscriber = null;
}
}
execute(); // run once immediately to establish initial dependencies
}
const [firstName, setFirstName] = createSignal('Ada');
const [lastName, setLastName] = createSignal('Lovelace');
createEffect(() => {
console.log(`Hello, ${firstName()} ${lastName()}`);
});
setFirstName('Grace'); // triggers the effect automatically
4. Computed Signals: abgeleitete Werte mit Memoization
Ein Computed Signal ist ein abgeleiteter Wert, der aus anderen Signals berechnet wird und dabei nur dann neu berechnet, wenn sich eine seiner tatsächlichen Abhängigkeiten ändert. Die Implementierung kombiniert die beiden bisherigen Bausteine: ein Computed Signal ist intern selbst ein Signal, dessen Wert über einen internen Effect aktuell gehalten wird. Liest ein weiterer Effect das Computed Signal, registriert er sich normal als Subscriber, genau wie bei einem einfachen Signal.
Der Memoization-Effekt entsteht dadurch, dass die interne Berechnung nur bei tatsächlicher Änderung einer Abhängigkeit läuft, nicht bei jedem Lesezugriff. Das unterscheidet Computed Signals von einer einfachen Getter-Funktion, die bei jedem Aufruf neu rechnet, unabhängig davon, ob sich etwas geändert hat. Bei teuren Berechnungen, etwa gefilterten und sortierten Listen mit tausenden Einträgen, macht dieser Unterschied den entscheidenden Performance-Vorteil aus.
// Computed signal: memoized derived value
function createComputed(computeFn) {
const [value, setValue] = createSignal(undefined);
createEffect(() => {
setValue(computeFn()); // only re-runs when a real dependency changes
});
return value;
}
const [price, setPrice] = createSignal(100);
const [quantity, setQuantity] = createSignal(2);
const total = createComputed(() => price() * quantity());
createEffect(() => {
console.log(`Total: ${total()}`);
});
setQuantity(3); // recomputes total, logs "Total: 300"
5. Batching: mehrere Updates zu einem Zyklus bündeln
Ohne Batching löst jeder Setter-Aufruf sofort alle abhängigen Effects aus, was bei mehreren aufeinanderfolgenden Änderungen zu redundanten Zwischenzuständen führt. Setzt ein Codeblock nacheinander drei Signals, ohne Batching, laufen abhängige Effects potenziell dreimal mit inkonsistenten Zwischenwerten. Das Muster batch(() => { ... }) sammelt alle Updates innerhalb der Funktion und löst die Benachrichtigung erst nach deren vollständigem Abschluss aus.
Die Implementierung von Batching erweitert den Setter um eine Prüfung, ob gerade ein Batch aktiv ist. Ist das der Fall, wird der betroffene Subscriber in ein Set gesammelt statt sofort ausgeführt, und erst am Ende des Batches werden alle gesammelten Subscriber einmalig aufgerufen. Dieses Muster ist bei Signals-Implementierungen wie in Solid oder Preact Signals Standard und verhindert, dass Nutzer inkonsistente Zwischenzustände sehen, etwa einen Warenkorb-Preis, der kurzzeitig mit veralteter Menge angezeigt wird.
// Batching multiple signal updates into a single notification cycle
let batchDepth = 0;
let pendingSubscribers = new Set();
function batch(fn) {
batchDepth += 1;
try {
fn();
} finally {
batchDepth -= 1;
if (batchDepth === 0) {
const toRun = pendingSubscribers;
pendingSubscribers = new Set();
for (const subscriber of toRun) subscriber();
}
}
}
// setSignalValue would check batchDepth and defer notification:
// if (batchDepth > 0) { subscribers.forEach(s => pendingSubscribers.add(s)); }
// else { subscribers.forEach(s => s()); }
batch(() => {
setFirstName('Alan');
setLastName('Turing');
// effect runs only once, after the batch completes
});
6. Cleanup und Speicherverwaltung
Ein häufig übersehenes Problem bei selbstgebauten Signals-Systemen ist, dass Subscriber-Sets unbegrenzt wachsen, wenn Effects nie ordnungsgemäß abgemeldet werden. Ein Effect, der zerstört wird, etwa weil die zugehörige UI-Komponente aus dem DOM entfernt wurde, muss sich aus allen Subscriber-Sets aller Signals entfernen, die er jemals gelesen hat, sonst hält die Referenz das gesamte Effect-Closure künstlich am Leben.
Die robuste Lösung: jeder Effect merkt sich bei jedem Lauf, welche Signale er registriert hat, und ruft vor jedem erneuten Lauf sowie beim endgültigen Dispose eine Cleanup-Funktion auf, die sich aus allen zuvor registrierten Subscriber-Sets austrägt. Frameworks wie Solid koppeln diesen Cleanup automatisch an den Component-Lifecycle, eine framework-unabhängige Signals-Bibliothek muss diese Verantwortung explizit an die aufrufende Anwendung zurückgeben, etwa über eine dispose()-Funktion, die beim Entfernen einer Komponente aufgerufen wird.
// Effect with explicit dispose to avoid subscriber leaks
function createDisposableEffect(fn) {
let dependencies = [];
function execute() {
cleanup();
activeSubscriber = execute;
try {
fn();
} finally {
activeSubscriber = null;
}
}
function cleanup() {
for (const subs of dependencies) subs.delete(execute);
dependencies = [];
}
execute();
return () => cleanup(); // dispose function, call on teardown
}
const disposeEffect = createDisposableEffect(() => {
console.log(`Total: ${total()}`);
});
// Later, when the component unmounts:
disposeEffect();
7. Signals in bestehende Frameworks integrieren
Eine framework-unabhängige Signals-Bibliothek lässt sich in bestehende Anwendungen einbinden, indem man an den Framework-eigenen Render-Zyklus koppelt. In einer React-Anwendung liest ein Custom Hook das Signal und abonniert per useSyncExternalStore, sodass React bei Änderungen automatisch neu rendert, ohne dass React seine eigene Reactivity verlassen muss. In Vue lässt sich ein externes Signal über customRef in das reaktive System einhängen, sodass Templates es wie ein natives ref behandeln.
Bei Web Components ist die Integration am direktesten: ein createEffect-Aufruf im connectedCallback aktualisiert das DOM direkt, und der zugehörige dispose() wird im disconnectedCallback aufgerufen. Diese Kompatibilität mit unterschiedlichen Rendering-Modellen ist der eigentliche Wert einer selbstgebauten Signals-Bibliothek: sie bleibt eine reine State-Management-Schicht, unabhängig davon, welches Framework das Rendering übernimmt.
8. Signals gegenüber anderen Reactivity-Modellen
Signals stehen nicht isoliert da, sondern konkurrieren mit älteren Reactivity-Modellen wie RxJS-Observables, Redux-Style-Reducern und Proxy-basierter Reaktivität, wie sie Vue 3 in seinem Reactivity-Core einsetzt. RxJS-Observables sind mächtiger für komplexe asynchrone Datenströme mit Operatoren wie debounceTime oder switchMap, bringen aber eine deutlich steilere Lernkurve mit als das simple Getter/Setter-Modell von Signals.
Redux-Style-State-Management mit einem zentralen Store und expliziten Reducern eignet sich gut für vorhersagbare, zeitreisbare Zustandsänderungen in großen Anwendungen, verlangt aber deutlich mehr Boilerplate als Signals für einfache, lokale reaktive Werte. Proxy-basierte Reaktivität, wie sie Vue intern nutzt, erreicht ähnliche Fine-Grained-Updates wie Signals, versteckt das Tracking aber hinter transparenten Objekt-Proxys statt expliziter Getter-Aufrufe, ein Kompromiss zwischen Ergonomie und Explizitheit.
9. Signals im Vergleich
Die Wahl zwischen Signals und alternativen State-Management-Ansätzen hängt stark vom Anwendungsfall ab: einfache, lokale UI-Zustände profitieren von der geringen Komplexität von Signals, während komplexe asynchrone Datenströme oder zeitreisbare globale Zustände andere Werkzeuge verlangen.
| Modell | Update-Granularität | Lernkurve | Bester Einsatz |
|---|---|---|---|
| Signals | Fine-grained, exakt | Niedrig | Lokaler reaktiver UI-Zustand |
| RxJS Observables | Stream-basiert | Hoch | Komplexe asynchrone Datenströme |
| Redux-Style-Store | Grob, Component-Ebene | Mittel | Große, zeitreisbare globale Zustände |
| Proxy-Reaktivität (Vue) | Fine-grained, implizit | Niedrig | Framework-integrierte Reaktivität |
| Virtual DOM Diffing | Komponentenweit | Niedrig | Einfache, weniger performance-kritische UIs |
In der Praxis zeigt sich, dass eine selbstgebaute Signals-Bibliothek besonders dann Wert bringt, wenn eine Anwendung framework-übergreifend eingesetzt werden soll, etwa als geteilte State-Management-Schicht zwischen einer React- und einer Web-Components-basierten Oberfläche. Die Grundprinzipien bleiben über alle Implementierungen identisch: Getter registrieren, Setter benachrichtigen, Computed Values memoizen, Batching konsistent halten.
Mironsoft
State-Management-Architektur und framework-übergreifende Reaktivität
Reaktivität, die nicht an ein Framework gebunden ist?
Wir bauen und integrieren framework-unabhängige Signals-Systeme mit Dependency-Tracking, Computed Values und Batching, angebunden an React, Vue oder native Web Components.
State-Audit
Analyse bestehender Reactivity-Muster auf Redundanzen und unnötige Re-Renders
Signals-Bibliothek
Aufbau einer eigenen, getesteten Signals-Implementierung für euren Stack
Framework-Bridge
Anbindung an React, Vue oder Web Components ohne Doppel-State
10. Zusammenfassung
Eine eigene Signals-Bibliothek besteht im Kern aus wenigen Bausteinen: einem Getter/Setter-Paar mit Subscriber-Set, einer globalen Tracking-Variable für implizite Abhängigkeitsregistrierung, Effects, die sich bei jedem Lauf neu registrieren, und Computed Values, die diese Bausteine für memoizierte, abgeleitete Werte kombinieren. Batching verhindert inkonsistente Zwischenzustände bei mehreren gleichzeitigen Updates, und explizites Cleanup verhindert Speicherlecks durch nie abgemeldete Subscriber.
Der Wert dieses Verständnisses liegt nicht nur im Bauen einer eigenen Bibliothek, sondern im Durchschauen bestehender Signals-Implementierungen in Vue, Solid, Preact und Angular, die alle auf denselben Grundprinzipien beruhen. Wer diese Mechanik einmal von Grund auf selbst implementiert hat, versteht Framework-Reaktivität nicht mehr als Blackbox, sondern als nachvollziehbares Zusammenspiel aus Getter-Tracking, Subscriber-Benachrichtigung und Memoization.
Eigene Signals-Bibliothek bauen — Das Wichtigste auf einen Blick
Grundbaustein
Getter registriert implizit den aktiven Effect, Setter benachrichtigt alle registrierten Subscriber.
Computed & Batching
Computed Signals memoizen abgeleitete Werte, batch() bündelt mehrere Updates zu einem Zyklus.
Cleanup
Effects müssen sich explizit aus Subscriber-Sets austragen, sonst entstehen Speicherlecks.
Integration
Über useSyncExternalStore (React), customRef (Vue) oder direkt in Web Components nutzbar.