$state ist die Rune, die du am häufigsten benutzen wirst. Sie löst genau ein Problem: Wie sage ich Svelte, dass eine Variable sich ändern kann und das UI darauf reagieren soll? Mit $state(initial) markierst du einen Wert als veränderlich-und-beobachtet. Was bei Objekten und Arrays passiert, ist dabei besonders praktisch — sogar tiefe Mutationen wie todo.done = true werden mitverfolgt. Dieser Artikel zeigt das Grundverhalten, die beiden Varianten $state.raw und $state.snapshot und die Punkte, an denen man sich leicht verläuft.

Grundprinzip

Eine reaktive Variable sieht aus wie eine normale Variable — ist sie aber nicht ganz.

svelte Counter.svelte
<script>
    let count = $state(0);
</script>

<button onclick={() => count++}>
    Klicks: {count}
</button>

Was passiert beim Klick:

  1. count++ schreibt einen neuen Wert in die Variable. So weit wie bei jeder anderen JavaScript-Variable.
  2. Der Unterschied: Der Svelte-Compiler hat sich beim Bauen gemerkt, dass {count} im Markup auftaucht. Er hat dort einen winzigen Update-Mechanismus eingebaut.
  3. Sobald sich count ändert, wird genau der <button>-Textinhalt aktualisiert — kein virtuelles DOM, kein Diffing über die ganze Komponente, nur dieser eine Knoten.

Das ist der Trick hinter $state: Es ist eine Markierung für den Compiler, kein Laufzeit-Wrapper. Im fertigen Code steht keine magische Klasse, sondern direkt die DOM-Update-Anweisungen, die Svelte braucht.

Du arbeitest dabei mit der Variable wie gewohnt: zuweisen, lesen, in Ausdrücken nutzen, in Bindings einsetzen.

Tiefe Reaktivität: warum Mutationen funktionieren

Jetzt wird es interessant. Bei Primitiven (Zahlen, Strings, Booleans) merkt sich Svelte einfach „die Variable wurde überschrieben”. Bei Objekten und Arrays wäre das aber zu grob — denn man möchte typischerweise nicht das ganze Array ersetzen, sondern nur einen einzelnen Eintrag ändern.

Damit das funktioniert, packt Svelte Objekte und Arrays in einen Proxy. Ein Proxy ist ein JavaScript-Konstrukt, das aussieht wie das Original-Objekt, aber bei jedem Lese- und Schreibzugriff einen Hook ausführt. So bekommt Svelte mit, welcher Eintrag sich geändert hat — und kann gezielt nur die abhängigen DOM-Stellen aktualisieren.

Praktisch heißt das: Du kannst genau so schreiben, wie du es in Vanilla-JavaScript würdest.

svelte Tiefe Reaktivität
<script>
    let todos = $state([
        { id: 1, text: 'Brot kaufen', done: false },
        { id: 2, text: 'Müll rausbringen', done: false },
    ]);

    function toggle(id) {
        const todo = todos.find((t) => t.id === id);
        if (todo) todo.done = !todo.done; // wird verfolgt
    }

    function add(text) {
        todos.push({ id: Date.now(), text, done: false }); // wird verfolgt
    }
</script>

<ul>
    {#each todos as todo (todo.id)}
        <li class:done={todo.done}>
            <input type="checkbox" checked={todo.done} onchange={() => toggle(todo.id)} />
            {todo.text}
        </li>
    {/each}
</ul>

Zwei Stellen sind hier interessant:

  • todo.done = !todo.done mutiert ein verschachteltes Property. Der Proxy fängt das ab und meldet die Änderung. Die Liste rendert genau diesen einen <input> neu.
  • todos.push(...) ist ebenfalls eine Mutation des Arrays. Auch das wird verfolgt — kein setTodos([...todos, neu]) wie in React nötig.

Wer aus React kommt, kann das Immutability-Pattern hier vergessen. In Svelte schreibt man so, wie es sich in JavaScript natürlich anfühlt.

Was wird automatisch tief reaktiv?

Der Proxy-Mechanismus deckt plain Objects (also normale { … }-Strukturen) und Arrays ab. Was er nicht abdeckt:

  • Map, Set, Date — diese Klassen lassen sich nicht so einfach in einen Proxy stecken, weil sie interne Methoden haben, die der Proxy nicht erkennen kann. Für sie gibt es Drop-in-Ersatz aus dem Modul svelte/reactivity: SvelteMap, SvelteSet, SvelteDate. API identisch, Reaktivität eingebaut.
  • Klassen-Instanzen anderer Art sind ebenfalls nicht automatisch reaktiv. Hier gibt es einen eleganten Trick: Schreibt man $state(...) direkt in das Property der Klasse, ist das einzelne Feld reaktiv — und damit faktisch die Instanz auch.
ts Reaktive Klasse
class Cart {
    items = $state<string[]>([]);
    total = $derived(this.items.length);

    add(item: string) {
        this.items.push(item);
    }
}

$state.raw – nur Reassign, keine Tiefe

Der Proxy-Wrapper aus dem letzten Abschnitt ist bequem, kostet aber etwas Performance — er fängt jeden einzelnen Property-Zugriff ab. Bei sehr großen Datenstrukturen (etwa einer Liste mit zehntausenden Einträgen) summiert sich das. Außerdem gibt es Bibliotheken, die mit Proxys nicht umgehen können und stattdessen das echte Objekt erwarten.

Für solche Fälle gibt es $state.raw. Damit sagst du Svelte: „Beobachte nur die Variable selbst — Mutationen darin sind mir egal.” Ein Update ist nur per kompletter Neuzuweisung möglich.

svelte $state.raw
<script>
    let person = $state.raw({ name: 'Heraklit', age: 49 });

    function ageUp() {
        // − wird NICHT verfolgt — Mutation auf raw
        person.age += 1;
    }

    function replace() {
        // + Reassign — wird verfolgt
        person = { name: 'Heraklit', age: 50 };
    }
</script>

Wer aus React kommt, kennt dieses Verhalten — dort ist es der Standard. In Svelte ist es der Spezialfall für drei Situationen: sehr große Datenmengen, in denen der Proxy-Overhead spürbar wird; Objekte aus externen Libraries, die nicht in einen Proxy passen; und Stellen, an denen man bewusst keine versehentlichen Mutationen erlauben will.

$state.snapshot – aus Proxy ein Plain-Objekt machen

Der Proxy ist beim Arbeiten in Svelte unsichtbar, aber sobald du den Wert nach außen gibst — an eine HTTP-Bibliothek, an JSON.stringify, an einen externen Logger — kann er Probleme machen. Manche Tools erwarten ein „echtes” Objekt und reagieren auf den Proxy mit ungewohnten Effekten (zum Beispiel rekursivem Logging, weil sie versuchen, die Property-Hooks zu inspizieren).

$state.snapshot(value) löst das Problem: Es gibt eine tiefe Kopie ohne Proxy zurück.

svelte Snapshot für JSON.stringify
<script>
    let user = $state({ name: 'Anna', tags: ['admin'] });

    function save() {
        const plain = $state.snapshot(user);
        fetch('/api/save', {
            method: 'POST',
            body: JSON.stringify(plain),
        });
    }
</script>

<button onclick={save}>Speichern</button>

Faustregel: Sobald reaktiver State eine API verlässt, die unter Sveltes Kontrolle steht, ist snapshot die saubere Übergabe.

$state als Klassen-Property

Wenn ein Stück State und seine Bearbeitungs-Logik zusammengehören (Timer, Cart, Auth-Manager …), ist eine Klasse oft übersichtlicher als ein loses Objekt mit Funktionen. Svelte 5 spielt damit gut zusammen — ein Property mit $state(...) macht das einzelne Feld reaktiv:

ts src/lib/timer.svelte.ts
export class Timer {
    elapsed = $state(0);
    running = $state(false);

    #intervalId?: number;

    start() {
        if (this.running) return;
        this.running = true;
        this.#intervalId = setInterval(() => {
            this.elapsed++;
        }, 1000);
    }

    stop() {
        this.running = false;
        clearInterval(this.#intervalId);
    }
}
svelte Timer-Verwendung
<script>
    import { Timer } from '$lib/timer.svelte';
    const timer = new Timer();
</script>

<p>Sekunden: {timer.elapsed}</p>
<button onclick={() => timer.start()} disabled={timer.running}>Start</button>
<button onclick={() => timer.stop()} disabled={!timer.running}>Stop</button>

Beim Lesen von außen — timer.elapsed im Markup — bekommt jeder Konsument den aktuellen Wert und reagiert auf Änderungen. Wer das gleiche Verhalten ohne Klasse möchte, kann auch ein Plain-Objekt zurückgeben — der Unterschied ist hier wirklich nur Stilfrage.

TypeScript-Typen für $state

Beim Initialwert leitet TypeScript den Typ ab. Manchmal will man explizit typisieren — etwa bei optionalen oder Union-Werten:

ts Explizite Typen
// Inferred: number
let count = $state(0);

// Explizit angeben
let user = $state<User | null>(null);

// Generic
let items = $state<string[]>([]);

Prop als Initial-Seed: das untrack-Pattern

Eine Konstellation, in die jeder Svelte-5-Entwickler früher oder später läuft: Eine Komponente bekommt einen Prop und will ihn als Startwert für einen eigenen lokalen $state übernehmen — danach soll der lokale Wert aber unabhängig leben. Klassisches Beispiel: Ein Formularfeld, das mit einem Wert vorbelegt wird, aber dem Nutzer beim Tippen gehört. Ein späterer Prop-Wechsel soll seine Eingabe nicht überschreiben.

svelte − Compiler warnt
<script>
    let { initialName = 'Anna' } = $props();
    let name = $state(initialName); // state_referenced_locally
</script>

<input bind:value={name} />

Die Warnung lautet sinngemäß: „Diese Referenz erfasst nur den Initialwert von initialName. Wolltest du sie in einem Closure referenzieren?” Der Compiler kann nicht wissen, ob diese Entkopplung Absicht ist oder ein Bug — also fragt er nach.

Lösung 1 – Gewollt entkoppelt: untrack

Wenn die Entkopplung Absicht ist (typisch: editierbares Form-Feld, das einen Prop nur als Vorbelegung kennt), wickelst du den Zugriff in untrack(...):

svelte + Mit untrack
<script>
    import { untrack } from 'svelte';

    let { initialName = 'Anna' } = $props();
    let name = $state(untrack(() => initialName));
</script>

<input bind:value={name} />

untrack sagt dem Compiler explizit: „Lies diesen reaktiven Wert außerhalb des Tracking-Kontexts.” Die Warnung verschwindet, das Verhalten bleibt identisch.

Lösung 2 – Soll synchron bleiben: $derived oder $effect

Wenn der lokale Wert sich tatsächlich aktualisieren soll, sobald die Prop sich ändert, ist $state falsch. Stattdessen $derived:

svelte Synchron mit Prop
<script>
    let { name } = $props();
    let upperName = $derived(name.toUpperCase());
</script>

Oder — wenn der Wert gleichzeitig vom Prop und vom Nutzer geändert werden kann — ein Reset-Effect:

svelte Reset bei Prop-Wechsel
<script>
    let { initialName } = $props();
    let name = $state(initialName);

    // Wenn der Eltern-Komponent eine neue Identität liefert, zurücksetzen
    $effect(() => {
        name = initialName;
    });
</script>

<input bind:value={name} />

Lösung 3 – Saubereres Reset über key-Pattern

Statt im Kind zu reagieren, kann der Eltern-Komponent die Komponente bei Identitätswechsel komplett neu mounten — siehe {#key}-Blocks. Damit braucht die Kind-Komponente weder untrack noch $effect.

svelte Eltern-Komponent mit key
{#key user.id}
    <ProfileCard initialName={user.name} />
{/key}

Faustregel

  • Initial-Seed gewollt, lokaler State bleibt unabhängiguntrack(() => prop) beim $state-Init.
  • Soll mit Prop synchron bleiben$derived(prop) oder $effect-Reset.
  • Identitätswechsel ist sauber{#key} im Eltern-Komponent.

Häufige Stolperfallen

Reaktivität durch Destrukturierung verlieren.

ts − Verliert Reaktivität
let user = $state({ name: 'Anna', age: 30 });
const { name } = user;
// name ist eine lokale Konstante — nicht reaktiv.

Lösung: Direkt user.name im Markup oder Funktion verwenden, statt zu destrukturieren.

$state für berechnete Werte verwenden.

ts − Falsch – wird out-of-sync
let count = $state(0);
let doubled = $state(count * 2); // einmalig berechnet, nicht reaktiv

Stattdessen: let doubled = $derived(count * 2);.

Map oder Set direkt in $state.

new Map(...) wird nicht reaktiv, wenn man set/delete darauf aufruft. Lösung: SvelteMap aus svelte/reactivity:

ts Reaktive Map
import { SvelteMap } from 'svelte/reactivity';

const cache = new SvelteMap<string, number>();
cache.set('a', 1); // reaktiv

Mutation in derselben Synchron-Tick mehrfach. Mehrere Mutationen kurz hintereinander werden zusammengefasst — Svelte rendert pro Microtask einmal. Du musst nichts manuell batchen.

Externe Mutation eines Proxy. Wenn du einen $state-Wert in eine Library reichst, die ihn intern hält, werden Mutationen von außen in der UI sichtbar — manchmal überraschend. In dem Fall: $state.snapshot(...) übergeben.

Praxis-Beispiel: Filter-Liste

svelte Searchable.svelte
<script>
    let query = $state('');
    let users = $state([
        { id: 1, name: 'Anna' },
        { id: 2, name: 'Bernd' },
        { id: 3, name: 'Carla' },
    ]);

    let filtered = $derived(
        users.filter((u) =>
            u.name.toLowerCase().includes(query.toLowerCase())
        )
    );
</script>

<input bind:value={query} placeholder="Suchen..." />

<ul>
    {#each filtered as user (user.id)}
        <li>{user.name}</li>
    {/each}
</ul>

query und users sind reaktive Quellen, filtered ist abgeleitet — bei jeder Eingabe aktualisiert sich die Liste automatisch.

Weiterführende Ressourcen

Externe Quellen

Verwandte Artikel

/ Weiter

Zurück zu Reactivity (Runes)

Zur Übersicht