Component Lifecycle
Svelte components have a lifecycle: they mount into the DOM, update when state changes, and eventually get destroyed. In Svelte 5, $effect is the primary tool for hooking into lifecycle events. The older onMount and onDestroy functions still work and have specific uses. Understanding when each runs helps you avoid bugs with subscriptions, intervals, and DOM manipulation.
$effect for Mounting & Cleanup
$effect runs after the component mounts and after every subsequent update where its dependencies change. The cleanup function runs before re-execution and when the component is destroyed.
<script>
let count = $state(0);
$effect(() => {
console.log('Component is in the DOM, count is:', count);
return () => {
console.log('Cleaning up for count:', count);
};
});
</script>
<button onclick={() => count++}>{count}</button>
// On mount:
Component is in the DOM, count is: 0
// After clicking:
Cleaning up for count: 0
Component is in the DOM, count is: 1
// On destroy:
Cleaning up for count: 1
This single pattern covers most lifecycle needs: setup and teardown for subscriptions, timers, event listeners, and external library integrations.
$effect for DOM Measurement
Since $effect runs after the DOM updates, it is safe to measure elements:
<script>
let content = $state('Short text');
let height = $state(0);
let container;
$effect(() => {
// Runs after DOM update, safe to measure
// Reading `content` here creates a dependency
void content;
height = container.getBoundingClientRect().height;
});
</script>
<div bind:this={container}>
<p>{content}</p>
</div>
<p>Container height: {height}px</p>
<button onclick={() => content += '\nAnother line of text'}>Add line</button>
$effect with No Dependencies
If your effect does not read any reactive values, it runs once on mount and the cleanup runs on destroy. This is equivalent to onMount with a cleanup return:
<script>
$effect(() => {
const handler = (e) => console.log('Key pressed:', e.key);
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
});
</script>
onMount: Browser-Only Code
onMount is a lifecycle function that runs once when the component first mounts to the DOM. Its key property: it only runs in the browser, never during server-side rendering.
<script>
import { onMount } from 'svelte';
let canvas;
let ctx;
onMount(() => {
ctx = canvas.getContext('2d');
ctx.fillStyle = '#ff3e00';
ctx.fillRect(10, 10, 100, 100);
return () => {
// Optional cleanup, runs on destroy
ctx = null;
};
});
</script>
<canvas bind:this={canvas} width="200" height="200"></canvas>
When to Use onMount vs $effect
Use onMount when:
- You need code that runs only in the browser (not during SSR).
- You are initializing a third-party library that requires DOM access.
- The setup is a one-time operation that does not depend on reactive state.
Use $effect when:
- The code should re-run when reactive state changes.
- You want automatic dependency tracking.
- You need cleanup that re-runs between updates, not just on destroy.
<script>
import { onMount } from 'svelte';
let map;
let mapContainer;
let center = $state({ lat: 40.7128, lng: -74.006 });
// One-time setup: browser-only, no reactive dependencies
onMount(() => {
map = new MapLibrary(mapContainer, { center, zoom: 12 });
return () => map.destroy();
});
// Reactive: re-run when center changes
$effect(() => {
if (map) {
map.setCenter(center.lat, center.lng);
}
});
</script>
<div bind:this={mapContainer} class="map"></div>
onDestroy: Cleanup Without Effects
onDestroy runs when the component is removed from the DOM. Unlike onMount, it runs during SSR as well (useful for cleanup of server-side state).
<script>
import { onDestroy } from 'svelte';
const interval = setInterval(() => {
console.log('tick');
}, 1000);
onDestroy(() => {
clearInterval(interval);
});
</script>
In practice, $effect's cleanup return handles most destruction needs. onDestroy is mainly useful when:
- You set up something outside of an
$effectoronMountthat needs cleanup. - You need cleanup logic that runs during SSR (rare but possible).
The Difference Between $effect & onMount
| Aspect | $effect | onMount |
|---|---|---|
| Runs on mount | Yes | Yes |
| Re-runs on state change | Yes (tracked dependencies) | No |
| Runs during SSR | No | No |
| Cleanup timing | Before re-run and on destroy | On destroy only |
| Dependency tracking | Automatic | None |
The practical distinction: onMount is "do this once when the component appears." $effect is "do this whenever relevant state changes, starting from when the component appears."
<script>
import { onMount } from 'svelte';
let data = $state(null);
let filter = $state('all');
// Runs once: fetch initial data
onMount(async () => {
const response = await fetch('/api/items');
data = await response.json();
});
// Runs whenever filter changes: log analytics
$effect(() => {
if (data) {
console.log(`User filtered by: ${filter}, ${data.length} items`);
}
});
</script>
Real-World Lifecycle Patterns
WebSocket Connection
<script lang="ts">
import { onMount } from 'svelte';
let messages = $state<string[]>([]);
let connected = $state(false);
let ws: WebSocket;
onMount(() => {
ws = new WebSocket('wss://example.com/chat');
ws.onopen = () => { connected = true; };
ws.onclose = () => { connected = false; };
ws.onmessage = (event) => {
messages.push(event.data);
};
return () => ws.close();
});
function send(text: string) {
if (ws && connected) ws.send(text);
}
</script>
Intersection Observer
<script>
let element;
let isVisible = $state(false);
$effect(() => {
const observer = new IntersectionObserver(
([entry]) => { isVisible = entry.isIntersecting; },
{ threshold: 0.5 }
);
observer.observe(element);
return () => observer.disconnect();
});
</script>
<div bind:this={element} class:visible={isVisible}>
{isVisible ? 'In view' : 'Out of view'}
</div>
Debounced Input with Cleanup
<script>
let search = $state('');
let results = $state([]);
$effect(() => {
if (search.length < 2) {
results = [];
return;
}
const timeout = setTimeout(async () => {
const response = await fetch(`/api/search?q=${encodeURIComponent(search)}`);
results = await response.json();
}, 300);
return () => clearTimeout(timeout);
});
</script>
<input bind:value={search} placeholder="Search...">
<ul>
{#each results as result}
<li>{result.name}</li>
{/each}
</ul>
Each keystroke clears the previous timeout (via cleanup) and starts a new one. The fetch only fires after 300ms of inactivity.
LocalStorage Persistence
<script>
import { onMount } from 'svelte';
let theme = $state('light');
onMount(() => {
const saved = localStorage.getItem('theme');
if (saved) theme = saved;
});
$effect(() => {
localStorage.setItem('theme', theme);
});
</script>
<select bind:value={theme}>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
onMount reads the saved value once (browser-only, so it does not break SSR). $effect persists changes whenever theme updates.
Keep Components Simple
Lifecycle hooks are powerful, but overusing them is a code smell. If a component has five effects, it is probably doing too much. Consider:
- Extracting logic into
.svelte.tsfiles: Reactive logic with effects can live in reusable modules. - Splitting large components: A component with many lifecycle concerns should probably be multiple components.
- Using effect: If you are computing a value, not performing a side effect,
$derivedis the right tool.
// src/lib/use-window-size.svelte.ts
export function useWindowSize() {
let width = $state(0);
let height = $state(0);
$effect(() => {
function update() {
width = window.innerWidth;
height = window.innerHeight;
}
update();
window.addEventListener('resize', update);
return () => window.removeEventListener('resize', update);
});
return {
get width() { return width; },
get height() { return height; }
};
}
<script>
import { useWindowSize } from '$lib/use-window-size.svelte';
const size = useWindowSize();
</script>
<p>Window: {size.width} x {size.height}</p>
Common Pitfalls
- Using onMount for reactive logic: If you need code to re-run when state changes, use
$effect, notonMountwith manual subscriptions. - Forgetting cleanup: Every
setInterval,addEventListener,subscribe, or external library initialization needs a corresponding cleanup. Without it, you leak memory and create ghost listeners. - Accessing the DOM before mount: The DOM is not available during SSR or before
onMount/$effectruns. Guard browser-specific code behindonMountor checktypeof window !== 'undefined'. - Async onMount without error handling:
onMount(async () => { ... })swallows errors silently. Add try/catch or handle the promise rejection. - Too many effects in one component: If you have more than two or three effects, the component is likely doing too much. Extract logic into reusable modules or split into smaller components.
Key Takeaways
$effectis the primary lifecycle tool in Svelte 5. It handles mount, update, and cleanup in one pattern.onMountruns once in the browser only. Use it for one-time setup that requires DOM access.onDestroyruns on cleanup. In most cases,$effect's return function is sufficient instead.- Always clean up subscriptions, intervals, and event listeners. The cleanup function in
$effectruns before re-execution and on component destruction. - Extract reusable lifecycle logic into
.svelte.tsfiles to keep components focused and simple. - If you are computing a value rather than performing a side effect, use
$derivedinstead of$effect.