Published 12/5/2025 · 5 min read
Tags: svelte , lifecycle , components
Lesson 10: Component Lifecycle
Components aren’t static. They’re created, updated, and eventually destroyed. Svelte 5 simplifies lifecycle management with the $effect rune.
The $effect Rune
In Svelte 5, most lifecycle needs are handled by $effect. It runs after the component mounts and re-runs when its dependencies change:
<script>
let data = $state(null)
$effect(() => {
fetch('/api/data')
.then(res => res.json())
.then(json => data = json)
})
</script>
{#if data}
<pre>{JSON.stringify(data, null, 2)}</pre>
{:else}
<p>Loading...</p>
{/if}
Why use $effect instead of running code directly?
Code in $effect only runs in the browser, not during server-side rendering. This makes it safe for fetch, DOM APIs, and browser-only code.
Cleanup:
Return a function from $effect to run cleanup when the component unmounts or before the effect re-runs:
<script>
let seconds = $state(0)
$effect(() => {
const interval = setInterval(() => {
seconds++
}, 1000)
// Cleanup function
return () => {
clearInterval(interval)
}
})
</script>
<p>Elapsed: {seconds} seconds</p>
This pattern is essential for preventing memory leaks.
Legacy Lifecycle Functions
Svelte 5 still supports the traditional lifecycle functions for compatibility, but $effect handles most use cases more elegantly:
<script>
import { onMount, onDestroy } from 'svelte'
// onMount still works
onMount(() => {
console.log('Component mounted')
return () => console.log('Cleanup')
})
// onDestroy still works
onDestroy(() => {
console.log('Component destroying')
})
</script>
When to use which:
$effect— Default choice. Handles mount, cleanup, and reactive updatesonMount— When you specifically need something to run only once at mountonDestroy— When you have cleanup not tied to a reactive effect
Scroll Position Example
Here’s a common pattern for auto-scrolling a chat container:
<script>
let messages = $state([])
let div
let autoscroll = $state(false)
$effect.pre(() => {
// Check scroll position before DOM updates
if (div) {
autoscroll = div.scrollHeight - div.scrollTop === div.clientHeight
}
})
$effect(() => {
// After DOM updates, scroll if needed
if (autoscroll && div) {
div.scrollTop = div.scrollHeight
}
})
</script>
<div bind:this={div} class="messages">
{#each messages as message}
<p>{message}</p>
{/each}
</div>
$effect.pre runs before DOM updates, while regular $effect runs after.
tick
Svelte batches DOM updates for performance. Sometimes you need to wait for the DOM to update before doing something.
<script>
import { tick } from 'svelte'
let text = $state('')
let input
async function addExclamation() {
text += '!'
// DOM hasn't updated yet
console.log(input.value) // Old value
await tick()
// Now DOM is updated
console.log(input.value) // New value with !
// Select all text
input.select()
}
</script>
<input bind:value={text} bind:this={input} />
<button onclick={addExclamation}>Add !</button>
Without await tick(), input.select() would run before the DOM updated.
Comparing to Vue
Vue’s lifecycle hooks:
<script setup>
import { onMounted, onUnmounted, watch } from "vue";
onMounted(() => {
console.log("Mounted");
});
onUnmounted(() => {
console.log("Unmounted");
});
watch(someValue, () => {
console.log("Value changed");
});
</script>
Svelte 5:
<script>
$effect(() => {
console.log('Mounted')
return () => console.log('Unmounted')
})
$effect(() => {
console.log('someValue changed:', someValue)
})
</script>
Svelte’s $effect combines mounting, cleanup, and watching into one unified pattern. Vue has nextTick, Svelte has tick.
Common Patterns
Fetching data:
<script>
let { userId } = $props()
let user = $state(null)
let loading = $state(true)
let error = $state(null)
$effect(() => {
loading = true
error = null
fetch(`/api/users/${userId}`)
.then(res => {
if (!res.ok) throw new Error('Failed to fetch')
return res.json()
})
.then(data => user = data)
.catch(e => error = e.message)
.finally(() => loading = false)
})
</script>
Setting up event listeners:
<script>
let windowWidth = $state(0)
$effect(() => {
function handleResize() {
windowWidth = window.innerWidth
}
handleResize() // Initial value
window.addEventListener('resize', handleResize)
return () => {
window.removeEventListener('resize', handleResize)
}
})
</script>
<p>Window width: {windowWidth}px</p>
Third-party library integration:
<script>
import Chart from 'chart.js/auto'
let { data } = $props()
let canvas
$effect(() => {
const chart = new Chart(canvas, {
type: 'bar',
data: {
labels: data.map(d => d.label),
datasets: [{
data: data.map(d => d.value)
}]
}
})
return () => chart.destroy()
})
</script>
<canvas bind:this={canvas} />
Effects and SSR
Important: $effect doesn’t run during server-side rendering. This is intentional — the DOM doesn’t exist on the server.
This means you can safely use browser-only APIs inside $effect:
<script>
let position = $state({ x: 0, y: 0 })
$effect(() => {
function handleMouseMove(event) {
position = { x: event.clientX, y: event.clientY }
}
window.addEventListener('mousemove', handleMouseMove)
return () => {
window.removeEventListener('mousemove', handleMouseMove)
}
})
</script>
If you try to access window directly in the script (outside $effect), you’ll get errors during SSR.
Multiple Effects
You can have multiple effects, each handling different concerns:
<script>
// Track window size
$effect(() => {
console.log('Setting up resize listener')
// ...
})
// Track mouse position
$effect(() => {
console.log('Setting up mouse listener')
// ...
})
</script>
Each effect runs independently and has its own cleanup.
Key Takeaways
$effectruns after mount and when dependencies change- Return a cleanup function to prevent memory leaks
$effect.preruns before DOM updatestick()returns a promise that resolves after pending updates- Effects don’t run during SSR — safe for browser APIs
- Legacy
onMount/onDestroystill work but$effectis preferred - Always clean up subscriptions, intervals, and event listeners
Related Articles
- Svelte 5 Runes: A Complete Guide for Vue Developers
A comprehensive guide to Svelte 5's runes system. Learn $state, $derived, $effect, $props, and $bindable with side-by-side Vue comparisons.
- Compressed NFTs: Collections, Verification, and Building a Claim Page
Taking our cNFT minting system to production: creating verified collections, building a web-based claim flow, and preparing for mainnet deployment.
- Adding Drizzle ORM to Your SvelteKit + SQLite Setup
Level up from raw SQL to type-safe queries with Drizzle—the lightweight ORM that doesn't hide the SQL you've learned.