$effect ist der Punkt, an dem reaktiver State die Außenwelt erreicht — sei es das DOM, der Browser-Title, ein Logger, ein Server, ein Timer oder eine Library. Während $state und $derived rein innerhalb von Svelte arbeiten, ist $effect die Brücke nach draußen. Wer aus React kommt, kennt das Konzept als useEffect. Der wichtige Unterschied: Es gibt keine Dependency-Liste — Svelte beobachtet automatisch, welche reaktiven Werte du im Body liest.
Grundprinzip
<script>
let count = $state(0);
$effect(() => {
document.title = `Klicks: ${count}`;
});
</script>
<button onclick={() => count++}>+1</button>Was hier passiert:
- Beim ersten Render der Komponente läuft der Effect-Body einmal. Der Browser-Title wird auf „Klicks: 0” gesetzt.
- Svelte hat dabei mitprotokolliert, dass im Body
countgelesen wurde —countist also eine Abhängigkeit. - Sobald
countsich ändert, läuft der Effect-Body erneut. Der Title wird aktualisiert. - Andere reaktive Werte, die im Body nicht gelesen werden, lösen den Effect nicht aus — selbst wenn sie sich ändern.
Genau das ist die Grundidee: Du beschreibst eine Wirkung, die mit bestimmten Werten in Sync bleiben soll, und Svelte kümmert sich darum, dass sie zur richtigen Zeit läuft.
Wann genau läuft ein Effect?
Der Zeitpunkt ist wichtig zu kennen, weil viele Bugs aus Missverständnissen darüber entstehen.
Nur im Browser, nicht beim SSR. Wer Server-Side-Rendering nutzt (z. B. mit SvelteKit), wird den Effect dort nicht ausführen. Browser-spezifische Code-Stellen (document, window, localStorage) sind also sicher — sie laufen nur, wenn das DOM tatsächlich existiert.
Nach dem DOM-Update, nicht währenddessen. Eine State-Änderung führt zuerst zum Re-Render — Svelte aktualisiert das DOM. Erst danach laufen die Effects. Wer also im Effect Geometrie misst (getBoundingClientRect()), bekommt die Werte des bereits aktualisierten DOM.
Pro Microtask einmal. Mehrere Änderungen in der gleichen synchronen Code-Strecke werden gebündelt. Wer hintereinander count++ und name = 'neu' macht, löst den Effect insgesamt einmal aus, nicht zweimal.
Mit Cleanup vor jedem Re-Run. Bevor der Effect erneut läuft (oder die Komponente unmounted wird), erhält der vorherige Lauf die Möglichkeit aufzuräumen — dazu gleich mehr.
Cleanup: warum eine Rückgabefunktion so wichtig ist
Viele Effects starten etwas, das später wieder beendet werden muss — einen Timer, einen Event-Listener, eine WebSocket-Verbindung, eine Subscription. Ohne Aufräumarbeit würde jeder erneute Lauf einen weiteren Timer dazustellen, der parallel zu den alten weiterläuft. Nach ein paar Updates: zehn Timer, jeder eine Sekunde später als der nächste. Memory-Leak und Chaos.
Die saubere Lösung: Du gibst eine Funktion aus dem Effect zurück. Svelte ruft sie auf, bevor der Effect erneut läuft (oder beim Unmount der Komponente).
<script>
let count = $state(0);
let intervalMs = $state(1000);
$effect(() => {
const id = setInterval(() => count++, intervalMs);
return () => clearInterval(id); // Cleanup
});
</script>
<p>{count}</p>
<input type="number" bind:value={intervalMs} />Der Ablauf in Worten:
- Beim Mount läuft der Effect-Body.
setIntervalstartet, der Timer zählt hoch. - Wenn
intervalMssich ändert, läuft erst die Cleanup-Funktion (clearIntervalfür den alten Timer), dann der Effect-Body neu (mit dem neuen Intervall). - Beim Unmount läuft die Cleanup-Funktion ein letztes Mal, der Timer wird sauber beendet.
Mit diesem Pattern verwaltest du Timer, Subscriptions, Listener und alles andere, was eingerichtet und wieder zurückgenommen werden muss — ohne dass eine alte Instanz im Hintergrund weiterläuft.
Was wird automatisch getrackt — und was nicht
Eine entscheidende Regel: Tracking funktioniert nur für Werte, die synchron im Effect-Body gelesen werden. Was hinter einem await oder in einem späteren Callback (setTimeout, Promise.then, Event-Listener) liegt, ist für Svelte unsichtbar.
Der Grund liegt in der Mechanik: Beim Aufruf des Effects merkt sich Svelte alle reaktiven Lesezugriffe, die während der Funktion passieren. Sobald die Funktion zurückkehrt, ist die Tracking-Phase vorbei. Asynchrone Fortsetzungen laufen außerhalb dieser Phase — sie können den Wert lesen, aber Svelte registriert es nicht mehr als Abhängigkeit.
let user = $state({ id: 1 });
let lastFetched = $state<User | null>(null);
$effect(() => {
// user.id wird getrackt
const id = user.id;
// setTimeout-Callback ist asynchron — wird NICHT getrackt
setTimeout(() => {
console.log(user.id); // Lesen hier triggert keinen Re-Run
}, 100);
});Manchmal ist genau das Gegenteil gefragt: Du willst einen reaktiven Wert lesen, ohne dass er zur Abhängigkeit wird. Etwa wenn du beim Klick den aktuellen Stand brauchst, der Effect aber nicht bei jeder Änderung erneut feuern soll. Dafür gibt es untrack(...):
import { untrack } from 'svelte';
$effect(() => {
// a wird getrackt, b nicht
console.log(a, untrack(() => b));
});$effect.pre – vor dem DOM-Update
Es gibt Situationen, in denen du vor dem nächsten Re-Render etwas im DOM erfassen willst — typisch beim Auto-Scroll-Verhalten in einem Chat: Du willst wissen, ob der Nutzer vorher ganz unten stand, bevor die neue Nachricht das Layout verändert. Nach dem Update ist diese Info verloren — der Scroll-Stand ist dann ein anderer.
Für solche Fälle gibt es $effect.pre. Identische Tracking-Regeln wie $effect, aber der Body läuft vor der DOM-Aktualisierung.
<script>
let messages = $state<Message[]>([]);
let container: HTMLElement;
let isAtBottom = $state(true);
$effect.pre(() => {
// läuft VOR dem DOM-Update
if (!container) return;
const distance = container.scrollHeight - container.scrollTop - container.clientHeight;
isAtBottom = distance < 20;
// Reaktive Abhängigkeiten dieser Funktion werden getrackt
messages.length;
});
$effect(() => {
if (isAtBottom) container.scrollTop = container.scrollHeight;
});
</script>
<div bind:this={container}>
{#each messages as msg}
<p>{msg.text}</p>
{/each}
</div>$effect.pre ist die richtige Wahl bei Auto-Scroll, Snapshots der DOM-Geometrie und ähnlichen Pre-Update-Bedürfnissen — selten, aber dann unverzichtbar.
$effect.root – manueller Scope
Normalerweise gehört jeder Effect zu einer Komponente — er startet beim Mount und endet beim Unmount. Manchmal brauchst du aber einen Effect, der außerhalb dieser Bindung läuft: in einem globalen Setup, in einem Test, in einem reaktiven Helper, der unabhängig von einer einzelnen Komponente lebt. Genau dafür gibt es $effect.root — du baust dir einen eigenen Scope und kontrollierst sein Ende selbst.
const cleanup = $effect.root(() => {
const interval = setInterval(() => console.log('tick'), 1000);
return () => clearInterval(interval);
});
// ... irgendwann später
cleanup(); // beendet den Root-Scope inkl. innerer EffectsTypisch für: App-weite Setups (Auth-Listener, Analytics), Vitest-Setups, eigene Custom-Stores. Im normalen Komponenten-Code wirst du $effect.root selten brauchen.
$effect.tracking() – sind wir im Reactive-Kontext?
Library-Code, der mit Reaktivität umgeht, will manchmal wissen, ob er gerade in einem Tracking-Kontext läuft (z. B. innerhalb eines Effects oder Derived). $effect.tracking() gibt darüber Auskunft:
function readValue(getter: () => any) {
if ($effect.tracking()) {
console.log('reaktiv – Tracking aktiv');
}
return getter();
}Für normalen Anwendungs-Code selten relevant; bei Library-Entwicklung gelegentlich nützlich.
Anti-Pattern: State-Synchronisierung
Der mit Abstand häufigste Effect-Fehler ist immer derselbe: Jemand schreibt einen Effect, der einen reaktiven Wert in einen anderen kopiert. Das ist immer ein Hinweis darauf, dass eigentlich ein $derived gemeint war.
<script>
let count = $state(0);
let doubled = $state(0);
$effect(() => {
doubled = count * 2;
});
</script><script>
let count = $state(0);
let doubled = $derived(count * 2);
</script>Drei Gründe, warum die zweite Variante besser ist: Sie ist schneller, weil $derived nur bei Bedarf rechnet und das Ergebnis cacht. Sie ist vorhersehbarer, weil der Wert synchron mit der Quelle aktualisiert wird, statt einen Tick zu warten. Und sie ist lesbarer, weil aus dem Code direkt hervorgeht, dass doubled eine Ableitung ist und kein eigener State.
Eine einfache Selbstprüfung beim Schreiben eines Effects: Wenn der Body einen reaktiven Wert schreibt, der sich aus anderen reaktiven Werten ergibt — wechsle auf $derived.
Praxis-Beispiele
LocalStorage-Sync
<script>
let theme = $state(localStorage.getItem('theme') ?? 'light');
$effect(() => {
localStorage.setItem('theme', theme);
document.documentElement.dataset.theme = theme;
});
</script>
<button onclick={() => theme = theme === 'light' ? 'dark' : 'light'}>
{theme}
</button>Fetch on demand
<script>
let { userId } = $props();
let user = $state(null);
$effect(() => {
let cancelled = false;
async function load() {
const res = await fetch(`/api/users/${userId}`);
const data = await res.json();
if (!cancelled) user = data;
}
load();
return () => { cancelled = true; };
});
</script>
{#if user}<h1>{user.name}</h1>{:else}<p>lädt …</p>{/if}Das cancelled-Flag verhindert Race-Conditions, wenn userId mehrmals schnell wechselt.
Subscription auf Window-Events
<script>
let width = $state(0);
$effect(() => {
const handler = () => width = window.innerWidth;
handler();
window.addEventListener('resize', handler);
return () => window.removeEventListener('resize', handler);
});
</script>
<p>Fensterbreite: {width}px</p>