S SvelteKurs
M7 · State-Management

Universeller State (.svelte.js / .svelte.ts)

Geteilter, reaktiver Zustand ohne Stores: Runes leben in einem Modul und werden von beliebig vielen Komponenten geteilt.

Runes außerhalb von Komponenten

$state & Co. funktionieren nicht nur in .svelte-Dateien. Wenn du eine Datei auf .svelte.js oder .svelte.ts endest, verarbeitet der Svelte-Compiler auch dort Runes. Damit kannst du reaktiven Zustand in ein ganz normales Modul legen — und jede Komponente, die dieses Modul importiert, teilt dieselbe Instanz dieses Zustands. Das ist Svelte-5-„universeller State": komponentenübergreifend, aber ohne die Store-API.

ℹ️ Die Datei-Endung ist entscheidend

Nur in .svelte.js/.svelte.ts-Dateien werden Runes compiliert. Eine gewöhnliche .ts-Datei mit $state führt zu einem Fehler.

Die Reexport-Falle

Es liegt nahe, einfach export let count = $state(0) zu schreiben — das funktioniert aber nicht wie erhofft. Beim Import würde nur der momentane Wert kopiert, nicht die reaktive Bindung. Andere Module sähen spätere Änderungen nie.

❌ so nicht ts
// ❌ FALSCH: Rune direkt als Top-Level-Variable exportieren
export let count = $state(0);

// Beim Import wird der WERT kopiert, nicht die reaktive Bindung.
// Andere Module sehen Änderungen nicht — die "Reexport-Falle".

Die Lösung: den Zustand in ein Objekt mit Gettern (oder Funktionen) kapseln. Der Getter liest den reaktiven Wert bei jedem Zugriff frisch aus — so bleibt die Reaktivität über die Modulgrenze hinweg erhalten.

counter.svelte.ts ts
// counter.svelte.ts — Datei-Endung .svelte.ts aktiviert Runes!
let count = $state(0);

export const counter = {
  get value() {
    return count;   // Getter liest den reaktiven Zustand LIVE
  },
  increment() {
    count += 1;
  },
  reset() {
    count = 0;
  }
};
Zaehler.svelte svelte
<script>
  import { counter } from '$lib/counter.svelte.js';
</script>

<!-- Jede Komponente, die das Modul importiert, teilt denselben Zustand -->
<button onclick={() => counter.increment()}>
  Geklickt: {counter.value}
</button>

⚠️ Warum ein Getter, kein einfaches Feld?

Würdest du { value: count } zurückgeben, wäre value ein eingefrorener Schnappschuss. Ein get value() hingegen wird bei jedem Lesen neu ausgewertet und meldet dem Compiler die Abhängigkeit — genau das hält die UI reaktiv.

Das echte Beispiel: theme.svelte.ts

Genau dieses Muster steckt in diesem Kurs. Der Theme-Umschalter oben rechts (Hell/Dunkel) wird von src/lib/theme.svelte.ts angetrieben — einem Runes-Modul mit exakt der Getter-Kapselung von oben:

src/lib/theme.svelte.ts ts
// src/lib/theme.svelte.ts — echtes Beispiel aus DIESEM Kurs
import { browser } from '$app/environment';

function createTheme() {
  let dark = $state(true);           // modulweiter reaktiver Zustand

  if (browser) {
    const stored = localStorage.getItem('theme');
    dark = stored ? stored === 'dark'
                  : document.documentElement.classList.contains('dark');
  }

  function apply() {
    if (!browser) return;
    document.documentElement.classList.toggle('dark', dark);
    localStorage.setItem('theme', dark ? 'dark' : 'light');
  }

  return {
    get dark() {   // Getter: gibt den LIVE-Wert zurück, nicht eine Kopie
      return dark;
    },
    toggle() {
      dark = !dark;
      apply();
    }
  };
}

// Eine einzige Instanz für die ganze App — der geteilte Zustand:
export const theme = createTheme();

Die letzte Zeile, export const theme = createTheme(), erzeugt genau eine Instanz. Jede Komponente, die theme importiert — der ThemeToggle im Header, ein Setting-Panel, was auch immer — greift auf denselben dark-Zustand zu. Ein Klick auf theme.toggle() ändert dark, und jede Stelle, die theme.dark liest, aktualisiert sich sofort:

ThemeToggle.svelte svelte
<script>
  import { theme } from '$lib/theme.svelte';
</script>

<button onclick={() => theme.toggle()}>
  Modus: {theme.dark ? '🌙 Dunkel' : '☀️ Hell'}
</button>

💡 Genau das erlebst du gerade

Der Umschalter in der Kopfzeile dieser Seite nutzt dieses Modul. Kein Store, kein Context — nur ein importiertes Objekt mit einem $state darin. Ein einziger Ort für den Zustand, den die ganze App teilt.

Live ausprobieren

Zur Abrundung ein kleiner lokaler Zähler in dieser Seite. Er nutzt dasselbe Getter-Prinzip nicht (er ist komponentenlokal) — verlagerst du sein $state aber in ein .svelte.ts-Modul, wäre er sofort app-weit teilbar:

▶ Lokaler $state (als Kontrast) · Interaktiv
0

Dieser Wert lebt nur in dieser Komponente. Im Modul wäre er global geteilt.

Einordnung zu Rails

🛤️ Rails-Vergleich: global sichtbar, aber reaktiv

Ein prozessweiter globaler Wert in Rails ist etwa eine Konstante oder ein Redis-Eintrag — überall sichtbar, aber statisch pro Request. Ein .svelte.ts-Modul ist das clientseitige Pendant: ein einziger, app-weit importierbarer Zustand — nur eben reaktiv, sodass jede Ansicht automatisch nachzieht, sobald er sich ändert.

Rails ruby
# Rails: ein prozessweiter, global geteilter Wert wäre z.B. eine
# Konstante oder ein Redis-Eintrag — aber statisch pro Request.
module AppConfig
  THEME_DEFAULT = "dark" # global sichtbar, aber NICHT reaktiv
end

# Ein .svelte.ts-Modul ist das clientseitige Gegenstück:
# global sichtbar UND reaktiv — die UI folgt jeder Änderung.

Zusammenfassung

💡 Das Wichtigste

  • Runes funktionieren in .svelte.js/.svelte.ts-Modulen — dort lebt geteilter, reaktiver Zustand.
  • Niemals export let x = $state(...) als Top-Level-Variable exportieren (Reexport-Falle).
  • Stattdessen in ein Objekt mit Gettern/Methoden kapseln — wie theme.svelte.ts in diesem Kurs.
  • Für neue Projekte ist dieses Muster oft die schlankere Alternative zu Stores; Stores bleiben stark bei async/RxJS-artigen Fällen.

🎯 Probier es selbst

Lege eine Datei counter.svelte.ts mit dem Getter-Muster von oben an, importiere counter in zwei verschiedene Komponenten und zeige in beiden counter.value an. Klicke in der einen — und beobachte, wie sich der Wert in der anderen mitbewegt.