Svelte Stores: Zustand außerhalb der Komponente
Svelte Stores sind ein minimales Modell für Zustand, der nicht in eine einzelne Komponente passt: writable, readable, derived – plus die $-Kurzschreibweise, die abonniert und beim Unmount aufräumt.
In meinen Svelte-Schulungen kommt fast immer derselbe Moment. Wir haben ein paar Komponenten gebaut, die Reaktivität funktioniert wunderbar innerhalb einer Komponente – und dann fragt jemand: „Und wenn zwei Komponenten sich denselben Wert teilen sollen, die aber gar nicht direkt verwandt sind?" Das ist der Punkt, an dem viele reflexartig nach einer großen State-Management-Bibliothek greifen, weil sie das aus der React-Welt so kennen. Meine Antwort ist dann meistens: Warte kurz, bevor du dir eine Abhängigkeit ins Projekt holst. Svelte hat dafür bereits ein Werkzeug an Bord, das kaum eine Handvoll Funktionen umfasst und für die allermeisten Fälle völlig ausreicht – Stores.
Ein Store ist Zustand, der außerhalb der Komponente steht. Er gehört keiner einzelnen .svelte-Datei, sondern liegt in einem separaten Modul und kann von überall aus abonniert werden. Damit löst er ein Problem, das in Komponentenbäumen schnell lästig wird: das Durchreichen von Werten durch mehrere Ebenen, nur damit ein tief verschachteltes Kind an eine Information kommt. Wer meinen Artikel zur Svelte-Reaktivität gelesen hat, kennt bereits das Fundament: Der Compiler baut die Reaktivität zur Bauzeit ein, ohne Virtual DOM. Stores sind die logische Fortsetzung dieses Gedankens für Zustand, der die Komponentengrenze überschreiten muss.
Der Contract, nicht die Klasse
Bevor ich zu den konkreten Funktionen komme, ein Punkt, der in der Praxis oft für Aha-Momente sorgt. Ein Store ist in Svelte kein spezieller Typ und keine Klasse, von der man erben muss. Ein Store ist schlicht jedes Objekt, das eine .subscribe-Methode nach einem festen Vertrag bereitstellt – und optional eine .set-Methode, wenn er schreibbar sein soll. Genau dieser Contract ist es, der zählt. Alles, was ihn erfüllt, funktioniert mit der $-Syntax und lässt sich in derived weiterverarbeiten.
In der Praxis schreibt man diese .subscribe-Methode fast nie selbst. Svelte liefert drei Fabrikfunktionen mit, die einem die Arbeit abnehmen. Sie stammen alle aus demselben Modul:
import { writable, readable, derived, get } from 'svelte/store';
Die drei Store-Typen decken drei unterschiedliche Rollen ab:
writable– les- und schreibbar. Der Standardfall für geteilten Zustand, den irgendeine Komponente oder Funktion verändern darf. Bietet.set(value),.update(fn)und.subscribe(run).readable– nur lesbar von außen. Der Wert wird intern über einen Start-Callback gesetzt, ideal für externe Quellen wie eine Uhr, einen WebSocket oder einen Event-Listener.derived– abgeleitet. Berechnet seinen Wert reaktiv aus einem oder mehreren anderen Stores und aktualisiert sich automatisch, wenn sich eine seiner Quellen ändert.
Der writable Store und die $-Kurzschreibweise
Fangen wir mit dem häufigsten Fall an – einem gemeinsamen Zähler, den mehrere Komponenten teilen. Der Store liegt in einem eigenen Modul, damit ihn jede Komponente importieren kann:
// stores.js
import { writable } from 'svelte/store';
export const count = writable(0);
Das ist der komplette Store. Ein Modul, eine Zeile, ein Anfangswert. Jetzt kann eine beliebige Komponente diesen Zähler lesen und schreiben – und hier kommt die eleganteste Idee von Svelte ins Spiel, die $-Kurzschreibweise:
<script>
import { count } from './stores.js';
</script>
<button on:click={() => $count++}>
Clicked {$count} times
</button>
Das $count im Template ist keine magische globale Variable, sondern eine Anweisung an den Compiler. Er erkennt das Präfix $ vor einer Store-Referenz und generiert daraus zweierlei: erstens ein count.subscribe(...), das den aktuellen Wert in eine lokale Variable schreibt und das Template bei jeder Änderung neu rendert; zweitens – und das ist der entscheidende Teil – ein automatisches unsubscribe, sobald die Komponente zerstört wird. Man muss sich um das Aufräumen also nicht kümmern, der Compiler nimmt es einem ab.
Auch das Schreiben ist über die Kurzschreibweise abgedeckt. $count++ übersetzt der Compiler in einen count.set(...)-Aufruf mit dem inkrementierten Wert. Wer es expliziter mag oder eine komplexere Änderung braucht, greift zu den Methoden direkt:
<script>
import { count } from './stores.js';
function increment() {
count.update(n => n + 1);
}
function reset() {
count.set(0);
}
</script>
<button on:click={increment}>+1</button>
<button on:click={reset}>Reset</button>
<p>Current value: {$count}</p>
Der Unterschied zwischen set und update ist schnell erklärt. set überschreibt den Wert bedingungslos, update bekommt den aktuellen Wert als Argument und gibt den neuen zurück – praktisch immer dann, wenn der neue Wert vom alten abhängt.
Und jetzt der Kern des Ganzen: Sobald eine zweite Komponente denselben Store importiert und $count verwendet, teilen sich beide denselben Zustand. Ändert die eine den Wert, sieht ihn die andere sofort. Kein Prop wird durch den Baum gereicht, keine gemeinsame Elternkomponente muss den Zustand halten. Der Store ist die gemeinsame Quelle, an der alle Beteiligten hängen.
graph TD S["writable count<br/>(stores.js)"] A["Component A<br/>$count lesen + schreiben"] B["Component B<br/>$count anzeigen"] C["Component C<br/>$count anzeigen"] S --> A S --> B S --> C A -. count.set() .-> S
Man sieht hier gut, was der Store leistet: Er entkoppelt die Komponenten voneinander. A, B und C müssen weder verwandt sein noch voneinander wissen. Sie kennen nur den Store.
Abgeleiteter Zustand mit derived
Oft braucht man einen Wert, der sich aus einem anderen ergibt. Man könnte ihn in jeder Komponente neu ausrechnen – oder man leitet ihn einmal zentral ab und alle profitieren. Genau dafür ist derived da:
// stores.js
import { writable, derived } from 'svelte/store';
export const count = writable(0);
export const doubled = derived(count, $count => $count * 2);
doubled ist selbst wieder ein Store, aber ein nur lesbarer. Er hat keine .set-Methode, denn sein Wert ergibt sich vollständig aus count. Ändert sich count, berechnet derived den Wert neu und benachrichtigt seine Abonnenten. In der Komponente sieht das dann so aus, als wäre es ein ganz normaler Store:
<script>
import { count, doubled } from './stores.js';
</script>
<p>{$count} doubled is {$doubled}</p>
<button on:click={() => $count++}>+1</button>
derived kann auch aus mehreren Quellen ableiten. Dann übergibt man ein Array von Stores und bekommt im Callback ein Array der Werte:
import { writable, derived } from 'svelte/store';
export const price = writable(100);
export const quantity = writable(2);
export const total = derived(
[price, quantity],
([$price, $quantity]) => $price * $quantity
);
Neben dieser synchronen Form gibt es eine zweite, die einen set-Callback bekommt. Damit lässt sich der abgeleitete Wert verzögert oder bedingt setzen – etwa nach einem Timeout oder erst, wenn eine bestimmte Bedingung erfüllt ist:
import { derived } from 'svelte/store';
// Sets the value one second after each change of 'count'
export const delayed = derived(
count,
($count, set) => {
const id = setTimeout(() => set($count), 1000);
return () => clearTimeout(id);
}
);
Der Rückgabewert der Callback-Funktion ist hier eine Aufräumfunktion, die Svelte vor der nächsten Berechnung und beim letzten Unsubscribe ausführt. So bleibt kein Timeout hängen.
readable für externe Quellen
Nicht jeder Zustand wird von der Anwendung selbst geschrieben. Manchmal kommt er von außen – eine Uhr, die tickt, ein WebSocket, der Nachrichten liefert, ein Sensor. Für solche Fälle gibt es readable. Von außen kann man ihn nur lesen; wer ihn füttert, ist ein Start-Callback, der die Quelle aufsetzt:
import { readable } from 'svelte/store';
export const time = readable(null, set => {
const id = setInterval(() => set(new Date()), 1000);
return () => clearInterval(id);
});
Hier lohnt sich ein genauer Blick auf das Timing, weil es ein zentrales Prinzip von Stores offenlegt. Der Start-Callback läuft nicht sofort beim Anlegen des Stores. Er läuft erst, wenn der erste Abonnent hinzukommt – wenn die Zahl der Subscriber von null auf eins springt. Und die zurückgegebene Funktion – der Stop-Teil – läuft, wenn der letzte Abonnent abspringt, also von eins auf null. Solange niemand zuhört, tickt kein Intervall, öffnet sich kein Socket. Diese Arbeit läuft lazy, sie startet verzögert erst bei Bedarf. Das gilt übrigens für alle Store-Typen, writable und derived eingeschlossen: Ohne Abonnenten passiert nichts.
In der Komponente ist der Umgang wieder trivial, denn $time abonniert automatisch und räumt beim Unmount auf:
<script>
import { time } from './stores.js';
</script>
<p>The time is {$time?.toLocaleTimeString()}</p>
Der Fallstrick: subscribe ohne unsubscribe
Bisher lief alles über die $-Kurzschreibweise, und dort ist das Aufräumen kein Thema. Aber Stores sind nicht auf Komponenten beschränkt. Man kann sie genauso in einem reinen JavaScript-Modul verwenden – in einer Utility-Funktion, einem Service, irgendwo außerhalb von Svelte. Und genau da lauert der häufigste Fehler.
Außerhalb einer Komponente gibt es kein $ und keinen Compiler, der aufräumt. Man ruft subscribe von Hand auf, und die Methode gibt eine Unsubscribe-Funktion zurück. Diese Funktion muss man aufheben und aufrufen, sonst hat man ein Leak:
// utils.js
import { count } from './stores.js';
// WRONG: the callback runs forever.
count.subscribe(value => {
console.log('count is now', value);
});
Der Rückgabewert von subscribe wird hier einfach ignoriert. Der Callback bleibt für immer registriert. Der Store zählt weiterhin einen Abonnenten, der Stop-Callback von 1→0 wird nie ausgelöst – bei einem readable mit Intervall liefe der Timer also endlos weiter. Richtig ist es so:
// utils.js
import { count } from './stores.js';
const unsubscribe = count.subscribe(value => {
console.log('count is now', value);
});
// ... later, when no longer needed:
unsubscribe();
Dieselbe Regel gilt sinngemäß auch innerhalb von Komponenten, falls man dort aus irgendeinem Grund manuell subscribe statt $ verwendet: Dann muss man den Unsubscriber in onDestroy aufrufen. Aber ehrlich gesagt ist das fast immer ein Zeichen, dass man einfach die $-Syntax hätte nehmen sollen. Genau diese Buchhaltung nimmt sie einem ab.
Wenn man den Wert nur ein einziges Mal lesen will – ohne Abonnement, ohne Reaktivität – gibt es einen sauberen Weg, der das Leak-Problem gar nicht erst aufkommen lässt:
import { get } from 'svelte/store';
import { count } from './stores.js';
const current = get(count);
get abonniert intern kurz, liest den aktuellen Wert und meldet sich sofort wieder ab. Der Wert ist eine Momentaufnahme und aktualisiert sich nicht mehr. Genau deshalb passt get gut für einmaliges Auslesen in einem JS-Modul – und genauso wenig gehört es in einen heißen Pfad, der ständig durchlaufen wird.
sequenceDiagram participant C as Erster Subscriber participant S as Store participant Q as Externe Quelle C->>S: subscribe(run) S->>Q: start-Callback (0 -> 1) Q-->>S: Werte fließen S-->>C: run(value) C->>S: unsubscribe() S->>Q: stop-Callback (1 -> 0)
Wann Store, und wann nicht
Der Store ist das richtige Werkzeug, sobald Zustand nicht mehr sauber in eine einzelne Komponente passt – wenn mehrere, nicht direkt verwandte Komponenten sich denselben Wert teilen, oder wenn ein Wert von außerhalb des Komponentenbaums kommt. Das klassische Symptom, das nach einem Store ruft, ist Prop-Drilling: Man reicht einen Wert durch drei, vier Ebenen, obwohl ihn nur die unterste Komponente wirklich braucht. Ein Store schneidet diesen Durchreiche-Pfad ab. Die betroffenen Komponenten importieren den Store direkt, und die Zwischenebenen müssen von dem Wert gar nichts wissen.
Jetzt zur Frage, die in Schulungen fast immer folgt: Brauche ich dann noch eine große State-Management-Bibliothek? Für die allermeisten Svelte-Anwendungen ist meine ehrliche Antwort: nein. Die drei Store-Typen decken erstaunlich viel ab. writable für den Zustand, den man verändert, derived für alles, was sich daraus ergibt, readable für externe Quellen. Das ist kein Kompromiss, kein „reicht erstmal" – es ist für den Großteil realer Anwendungen schlicht ausreichend. Ein zentrales Store-Modul, das die einzelnen Stores exportiert, gibt der Anwendung eine klare, überschaubare Struktur, ohne dass man ein weiteres mentales Modell und eine weitere Abhängigkeit mitschleppen muss.
Wo liegt dann der Rand, ab dem man über mehr nachdenkt? Ich würde sagen: Erst wenn der Zustand wirklich komplex wird – viele voneinander abhängige Übergänge, strikte Nachvollziehbarkeit jeder Änderung, ausgefeiltes Time-Travel-Debugging über eine große Domäne hinweg. Solche Anforderungen sind selten, und selbst dann baut man häufig eine dünne Schicht auf Stores auf, statt sie zu ersetzen. Solange die Frage aber nur lautet „Wie teilen sich meine Komponenten einen Wert?", ist die Antwort ein Store – und mehr braucht es nicht.
Was ich aus vielen Projekten mitnehme: Die Zurückhaltung zahlt sich aus. Wer mit writable, readable und derived beginnt und erst dann nach mehr greift, wenn ein konkretes Problem es erzwingt, hält seine Anwendung schlank. Die $-Kurzschreibweise sorgt dabei im Alltag dafür, dass man an Subscriptions und deren Aufräumen kaum je denken muss – und die einzige Stelle, an der man wachsam bleiben sollte, ist das manuelle subscribe außerhalb einer Komponente. Merkt man sich dort die eine Regel – jeden subscribe-Aufruf mit seinem unsubscribe paaren – dann sind Stores genau das, was Svelte im Kleinen so angenehm macht: ein minimales Modell, das genau das tut, was man erwartet.
Weiterführende Quellen
- Repository: mikebild.dev – dieser und weitere Artikel zu Svelte und Frontend-Architektur
- Svelte-Reaktivität: der Compiler ohne Virtual DOM
- Svelte-Docs – Store-API-Referenz
- Svelte-Tutorial – Writable Stores
- Svelte-Tutorial – Auto-Subscriptions
- Svelte-Tutorial – Readable Stores
- Svelte-Tutorial – Derived Stores
Kommentare