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.
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:
- Wir erzeugen intern einen
writable-Store mit dem Initialwert. - 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. - Wichtig: Wir geben nicht
setundupdateheraus. Damit kann niemand außerhalb beliebige Werte setzen — nur die kontrollierten Methoden funktionieren.
<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.
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();<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.
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.
import { persistent } from '$lib/stores/persistent';
export const theme = persistent<'light' | 'dark'>('theme', 'light');<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:
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();<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:
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);
},
};
}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
writablemit Lifecycle-Funktion. - Wenn der Store niemals von außen gesetzt werden soll ->
set/updatenicht herausreichen, nur Methoden. - Wenn nur Lesen ->
derivedist meistens die richtige Wahl, kein Custom-Pattern nötig.
Häufige Stolperfallen
set und update aus Versehen mit-exportieren.
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.