The Escape Hatch Misconception
If you’re coming from React, you’ll see $effect and think “useEffect.” If you’re coming from Vue, you’ll think “watch.” If you’re coming from Svelte 4, you’ll think “reactive statements.”
All of these intuitions will lead you astray.
$effect is for side effects—things that reach outside your component to interact with the world. It’s not for computing values. It’s not for synchronizing state. It’s not for fetching data on mount.
Most components don’t need $effect at all. The ones that do use it sparingly.
What $effect Actually Does
$effect runs a function when its dependencies change. Svelte tracks what you read inside the function and re-runs it when those values update.
<script>
let count = $state(0)
$effect(() => {
console.log(`Count is now ${count}`)
})
</script>
<button onclick={() => count++}>Increment</button> Every time count changes, the effect runs. The console logs the new value.
This seems useful. It is useful—for specific things. The problem is that it’s too useful. It can do almost anything, which makes it tempting to use for everything.
Don’t.
When NOT to Use $effect
Don’t use $effect to compute values
If you’re deriving a value from other values, use $derived.
<script>
let items = $state([1, 2, 3, 4, 5])
// ❌ Wrong: using effect to compute
let total = $state(0)
$effect(() => {
total = items.reduce((a, b) => a + b, 0)
})
// ✅ Right: using derived
let total = $derived(items.reduce((a, b) => a + b, 0))
</script> The effect version has an extra piece of state (total), an extra concept (the effect), and a timing gap (the effect runs after the render, so total might briefly be stale).
The derived version is simpler, always consistent, and has no timing issues.
Don’t use $effect to synchronize state
If two pieces of state should stay in sync, one should be derived from the other.
<script>
let celsius = $state(0)
// ❌ Wrong: syncing state with effects
let fahrenheit = $state(32)
$effect(() => {
fahrenheit = (celsius * 9) / 5 + 32
})
// ✅ Right: derive one from the other
let fahrenheit = $derived((celsius * 9) / 5 + 32)
</script> If you need bidirectional conversion (change either and update the other), use event handlers:
<script>
let celsius = $state(0)
let fahrenheit = $derived((celsius * 9) / 5 + 32)
function setCelsius(value) {
celsius = value
}
function setFahrenheit(value) {
celsius = ((value - 32) * 5) / 9
}
</script>
<input type="number" value={celsius} oninput={(e) => setCelsius(+e.target.value)} />
<input type="number" value={fahrenheit} oninput={(e) => setFahrenheit(+e.target.value)} /> One source of truth (celsius), one derived value (fahrenheit), explicit functions for each direction. No effects needed.
Don’t use $effect to fetch data on “mount”
In SvelteKit, data fetching belongs in load functions, not effects.
<script>
let { userId } = $props()
// ❌ Wrong: fetching in effect
let user = $state(null)
$effect(() => {
fetch(`/api/users/${userId}`)
.then((r) => r.json())
.then((data) => (user = data))
})
</script> This has problems:
- No loading state handling
- No error handling
- Runs on the client only (bad for SEO)
- Race conditions if
userIdchanges quickly
Use a load function instead:
// +page.server.js
export async function load({ params }) {
const user = await db.getUser(params.userId)
return { user }
} <!-- +page.svelte -->
<script>
let { data } = $props()
</script>
<h1>{data.user.name}</h1> Load functions run on the server, handle errors properly, and integrate with SvelteKit’s navigation system.
When To Use $effect
Effects are for side effects—operations that reach outside your component to interact with the external world.
DOM manipulation
When you need to work with the actual DOM, effects are appropriate:
<script>
let canvas
let color = $state('#ff0000')
let size = $state(50)
$effect(() => {
const ctx = canvas.getContext('2d')
ctx.clearRect(0, 0, canvas.width, canvas.height)
ctx.fillStyle = color
ctx.fillRect(0, 0, size, size)
})
</script>
<canvas bind:this={canvas} width="200" height="200"></canvas>
<input type="color" bind:value={color} />
<input type="range" bind:value={size} min="10" max="100" /> The effect draws on the canvas whenever color or size changes. This is a side effect—you’re imperatively manipulating the DOM.
Third-party library integration
Libraries that aren’t Svelte-aware often need imperative setup:
<script>
import mapboxgl from 'mapbox-gl'
let container
let center = $state([-74.5, 40])
let zoom = $state(9)
$effect(() => {
const map = new mapboxgl.Map({
container,
center,
zoom
})
return () => map.remove() // Cleanup
})
</script>
<div bind:this={container} class="map"></div> The effect creates the map when the component mounts and cleans it up when the component unmounts or the effect re-runs.
Subscriptions and event listeners
When you need to subscribe to external event sources:
<script>
let windowWidth = $state(window.innerWidth)
$effect(() => {
function handleResize() {
windowWidth = window.innerWidth
}
window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
})
</script>
<p>Window width: {windowWidth}px</p> The effect sets up a listener and returns a cleanup function that removes it.
Document title and other browser APIs
<script>
let { title } = $props()
$effect(() => {
document.title = title
})
</script> Short, focused, clearly a side effect. This is a good use of $effect.
Analytics and logging
<script>
let { page } = $props()
$effect(() => {
analytics.trackPageView(page)
})
</script> Sending data to external services is inherently a side effect.
The Cleanup Pattern
Effects can return a cleanup function. This runs:
- Before the effect re-runs (when dependencies change)
- When the component is destroyed
<script>
let interval = $state(1000)
let count = $state(0)
$effect(() => {
const id = setInterval(() => {
count++
}, interval)
// Cleanup: clear the interval
return () => clearInterval(id)
})
</script>
<p>Count: {count}</p>
<input type="range" bind:value={interval} min="100" max="2000" /> When interval changes, the old interval is cleared before a new one is created. When the component unmounts, the interval is cleared. No memory leaks.
$effect.pre for Before-DOM Updates
Sometimes you need to run code before Svelte updates the DOM. Use $effect.pre:
<script>
let messages = $state([])
let viewport
$effect.pre(() => {
// Runs BEFORE DOM updates
// Check if we should auto-scroll
if (viewport) {
const isAtBottom = viewport.scrollTop + viewport.clientHeight >= viewport.scrollHeight - 20
if (isAtBottom) {
// After DOM updates, scroll to bottom
tick().then(() => {
viewport.scrollTop = viewport.scrollHeight
})
}
}
})
</script>
<div bind:this={viewport} class="messages">
{#each messages as message}
<p>{message}</p>
{/each}
</div> This is rare. Most effects should run after the DOM updates. But when you need to measure something before it changes, $effect.pre is there.
The Decision Tree
When you’re about to write $effect, ask yourself:
Am I computing a value from other values? → Use
$derivedAm I keeping two pieces of state in sync? → Derive one from the other, or use event handlers
Am I fetching data? → Use a load function (SvelteKit) or handle it in an event
Am I interacting with the DOM, browser APIs, or external services? → Use
$effect✓
If you can’t answer “yes” to #4, you probably don’t need an effect.
The Mindset
Think of $effect as an escape hatch, not a primary tool.
In a well-structured Svelte application:
- Most logic is in
$derived(computed values) - Most mutations happen in event handlers
- Most data comes from load functions
$effecthandles the small surface area where your component touches the outside world
If you find yourself writing lots of effects, step back. There’s probably a simpler approach using derived state or explicit event handlers.
Effects are powerful. That’s why they’re dangerous. Use them sparingly, and your components will be easier to understand, test, and maintain.
Next up: We’re done with philosophy. Time to build. We’ll start with SvelteKit’s routing system and why routes aren’t just organization—they’re architecture.