Manchmal reichen die nackten set/update-Methoden eines writable-Stores nicht aus — du willst stattdessen eigene benannte Operationen wie addTodo, markAllAsRead oder reset anbieten. Genau das ist das Pattern „Custom Store”: einen normalen Store nehmen und ihn in ein Objekt mit eigener API einpacken. Das macht den Code an der Aufrufstelle deutlich klarer. Dieser Artikel zeigt drei typische Custom-Store-Patterns mit vollständigem Code.

Das Grundprinzip

Ein Custom Store ist im einfachsten Fall: ein normaler writable plus zusätzliche Funktionen, gebündelt in einem zurückgegebenen Objekt.

ts Counter mit eigenem API
import { writable } from 'svelte/store';

function createCounter(initial = 0) {
    const { subscribe, set, update } = writable(initial);

    return {
        subscribe,
        increment: () => update((n) => n + 1),
        decrement: () => update((n) => n - 1),
        reset: () => set(initial),
    };
}

export const counter = createCounter();

Schritt für Schritt:

  1. Wir erzeugen intern einen writable-Store mit dem Initialwert.
  2. Aus dem Rückgabe-Objekt nehmen wir nur, was wir nach außen geben wollen — subscribe (damit es ein gültiger Store bleibt) plus eigene Methoden.
  3. Wichtig: Wir geben nicht set und update heraus. Damit kann niemand außerhalb beliebige Werte setzen — nur die kontrollierten Methoden funktionieren.
svelte Verwendung im Markup
<script>
    import { counter } from '$lib/stores/counter';
</script>

<p>{$counter}</p>
<button onclick={counter.increment}>+1</button>
<button onclick={counter.decrement}>−1</button>
<button onclick={counter.reset}>Reset</button>

$counter funktioniert wegen subscribe. Die Aktionen sind klar benannt — kein count.update(n => n + 1) mehr im Komponenten-Code.

Pattern: Toggle-Store

Ein häufiger Fall: Ein Boolean, der per Klick umgeschaltet werden soll.

ts Toggle-Store
import { writable } from 'svelte/store';

function createToggle(initial = false) {
    const { subscribe, set, update } = writable(initial);

    return {
        subscribe,
        toggle: () => update((value) => !value),
        on: () => set(true),
        off: () => set(false),
    };
}

export const sidebar = createToggle();
svelte Verwendung
<script>
    import { sidebar } from '$lib/stores/sidebar';
</script>

<button onclick={sidebar.toggle}>Sidebar umschalten</button>

{#if $sidebar}
    <aside>Sidebar-Inhalt</aside>
{/if}

Statt überall $sidebar = !$sidebar zu schreiben, gibt es eine semantische Methode: sidebar.toggle(). Der Code an der Aufrufstelle liest sich wie deutsche Sätze.

Pattern: localStorage-Sync

Klassiker: Ein Store, der seinen Wert beim Laden aus localStorage initialisiert und bei jeder Änderung wieder zurückschreibt. So bleibt der State über Reloads erhalten.

ts src/lib/stores/persistent.ts
import { writable } from 'svelte/store';
import { browser } from '$app/environment';

export function persistent<T>(key: string, initial: T) {
    // Beim Modul-Laden: Versuche, vom localStorage zu laden
    const stored = browser ? localStorage.getItem(key) : null;
    const startValue: T = stored !== null ? JSON.parse(stored) : initial;

    const { subscribe, set, update } = writable<T>(startValue);

    return {
        subscribe,
        set: (value: T) => {
            if (browser) localStorage.setItem(key, JSON.stringify(value));
            set(value);
        },
        update: (fn: (value: T) => T) => {
            update((value) => {
                const next = fn(value);
                if (browser) localStorage.setItem(key, JSON.stringify(next));
                return next;
            });
        },
        reset: () => {
            if (browser) localStorage.removeItem(key);
            set(initial);
        },
    };
}

browser aus $app/environment (in SvelteKit) ist true nur im Browser — beim Server-Rendering ist localStorage ja nicht verfügbar.

ts Verwendung
import { persistent } from '$lib/stores/persistent';

export const theme = persistent<'light' | 'dark'>('theme', 'light');
svelte Im Markup
<script>
    import { theme } from '$lib/stores/theme';
</script>

<button onclick={() => theme.set($theme === 'light' ? 'dark' : 'light')}>
    Theme: {$theme}
</button>

Beim nächsten Page-Reload ist die Wahl noch da — ohne dass die Komponente etwas davon weiß.

Pattern: Listen-Store mit Domain-Methoden

Ein Todo-Store, der eigene Operationen statt set/update anbietet:

ts src/lib/stores/todos.ts
import { writable } from 'svelte/store';

export type Todo = {
    id: string;
    text: string;
    done: boolean;
};

function createTodos() {
    const { subscribe, set, update } = writable<Todo[]>([]);

    return {
        subscribe,
        add: (text: string) => {
            update((current) => [
                ...current,
                { id: crypto.randomUUID(), text, done: false },
            ]);
        },
        toggle: (id: string) => {
            update((current) =>
                current.map((todo) =>
                    todo.id === id ? { ...todo, done: !todo.done } : todo
                )
            );
        },
        remove: (id: string) => {
            update((current) => current.filter((todo) => todo.id !== id));
        },
        clearCompleted: () => {
            update((current) => current.filter((todo) => !todo.done));
        },
        reset: () => set([]),
    };
}

export const todos = createTodos();
svelte Im Markup
<script>
    import { todos } from '$lib/stores/todos';

    let input = $state('');

    function add() {
        if (!input.trim()) return;
        todos.add(input);
        input = '';
    }
</script>

<form onsubmit={(e) => { e.preventDefault(); add(); }}>
    <input bind:value={input} placeholder="Neue Aufgabe …" />
    <button type="submit">Hinzufügen</button>
</form>

<ul>
    {#each $todos as todo (todo.id)}
        <li>
            <input type="checkbox" checked={todo.done} onchange={() => todos.toggle(todo.id)} />
            {todo.text}
            <button onclick={() => todos.remove(todo.id)}>×</button>
        </li>
    {/each}
</ul>

<button onclick={todos.clearCompleted}>Erledigte entfernen</button>

Die Komponente hat keine Ahnung von der Datenstruktur. Sie kennt nur todos.add(...), todos.toggle(...) etc. Das ist gute Trennung von Verantwortung.

Den Store-Vertrag selbst implementieren

Du musst nicht writable als Basis nehmen. Ein gültiger Store ist alles, was die subscribe-Methode anbietet. Manchmal hast du eine externe Quelle (z. B. eine RxJS-Observable, einen WebSocket, eine Browser-API), die du als Store kapseln willst:

ts Manueller Store
import type { Readable } from 'svelte/store';

export function fromEventTarget<E extends Event>(
    target: EventTarget,
    event: string,
    initial: E | null = null
): Readable<E | null> {
    return {
        subscribe(callback) {
            callback(initial);
            const handler = (event: Event) => callback(event as E);
            target.addEventListener(event, handler as EventListener);
            return () => target.removeEventListener(event, handler as EventListener);
        },
    };
}
ts Verwendung
export const click = fromEventTarget<MouseEvent>(document, 'click');

Die Komponente kann $click lesen — und bekommt jeden Klick als reaktiven Wert mit. Die subscribe-Funktion folgt exakt dem Store-Vertrag: Sie wird sofort aufgerufen (callback(initial)), gibt eine Unsubscribe-Funktion zurück, und ruft den Callback bei jedem Event erneut.

Faustregeln für Custom Stores

  • Wenn dieselbe update(...)-Logik an mehreren Stellen vorkommt -> in eine benannte Methode auslagern.
  • Wenn der Store eine externe Quelle einkapselt (Storage, Socket, RxJS) -> eigener Store-Vertrag oder writable mit Lifecycle-Funktion.
  • Wenn der Store niemals von außen gesetzt werden soll -> set/update nicht herausreichen, nur Methoden.
  • Wenn nur Lesen -> derived ist meistens die richtige Wahl, kein Custom-Pattern nötig.

Häufige Stolperfallen

set und update aus Versehen mit-exportieren.

ts − kein klares API
function createCounter() {
    const store = writable(0);
    return store; // gibt set/update mit raus
}

Die Aufrufstelle kann jetzt counter.set(99) machen — was der Sinn von Custom Stores eigentlich verhindert. Lieber explizit destrukturieren und nur exportieren, was nach außen darf.

subscribe vergessen. Ohne subscribe ist es kein gültiger Store — $store funktioniert nicht. Die Methode muss immer mit-exportiert werden.

Initialwert via localStorage ohne Browser-Check. Im SSR-Kontext ist localStorage nicht definiert. Mit browser-Flag schützen oder Initialwert beim ersten Mount aktualisieren.

Vergessenes Cleanup bei externen Quellen. Wenn dein manueller Store einen Listener oder Timer registriert, muss er ihn auch wieder entfernen. Sonst leckt jeder neue Subscriber Ressourcen.

Reaktivität in komplexen Strukturen verlieren. Wenn ein Store ein verschachteltes Objekt hält, lösen tiefe Mutationen kein Update aus. Entweder neue Objekte zurückgeben (Spread) oder einen tief reaktiven $state-Container nutzen — siehe Stores vs. $state.

Weiterführende Ressourcen

Externe Quellen

Verwandte Artikel

/ Weiter

Zurück zu Stores

Zur Übersicht