Lifecycle and Cleanup Patterns in Svelte 5
What are effects
Effects are one of the most powerful—and most misunderstood—features in Svelte 5. They let you synchronize your reactive state with the outside world: the DOM, browser APIs, third-party libraries, or any system that doesn’t “speak Svelte.” But with great power comes great responsibility. Used incorrectly, effects can cause performance problems, memory leaks, and the dreaded infinite loop.
This tutorial takes you from the fundamentals of what effects are, through the nuances of when they run and how to clean them up properly, all the way to advanced patterns that will make your Svelte 5 applications robust and maintainable.
REMEMBERIn 90% of cases you don’t need an
$effect()at all !!!
Why is this important?
Most data flow stays entirely inside Svelte’s reactive graph: use $state for data, $derived/$derived.by for computed values, and ordinary event handlers/attributes for DOM updates.
Reach for an effect only when you must touch something Svelte cannot manage for you (browser APIs, timers, subscriptions, imperative libraries). A quick gut-check: if the code does not talk to the outside world, it probably belongs in $derived or straight markup, not in an effect.
Performance TipEffects run asynchronously after DOM updates. This batching prevents unnecessary re-runs and keeps your app responsive.
What Is an Effect and Why Do We Need It?
The Concept: Bridging Two Worlds
Imagine your Svelte application as a self-contained reactive universe. Inside this universe, state changes flow automatically—when you update a $state variable, anything that depends on it (including your UI) updates too. This is reactive synchronization, and Svelte handles it beautifully.
But what happens when you need to interact with something outside this universe? Things like:
- Drawing on a
<canvas>element - Setting up a
setIntervaltimer - Subscribing to a WebSocket connection
- Integrating a third-party charting library
- Logging analytics events
- Storing data in
localStorage
These external systems don’t understand Svelte’s reactivity. They don’t automatically update when your state changes. This is where effects come in—they’re the bridge between Svelte’s reactive world and everything outside it.
The Definition
An effect is a function that runs in response to state changes. It’s how you say to Svelte: “Whenever these pieces of state change, run this code.”
<script>
let count = $state(0)
// This effect runs whenever `count` changes
$effect(() => {
console.log(`The count is now: ${count}`)
})
</script>
<button onclick={() => count++}>
Increment: {count}
</button> What happens here:
- When the component first mounts, the effect runs once, logging “The count is now: 0”
- Each time you click the button,
countchanges - Svelte detects that the effect depends on
count - The effect re-runs, logging the new value
Effects Are an “Escape Hatch”
Here’s something crucial to understand: effects should be your last resort, not your first tool. The Svelte documentation explicitly calls them an “escape hatch.” Why?
Because Svelte’s reactive system—$state and $derived—is designed to handle most data synchronization automatically. Effects are for when you need to step outside that system. If you find yourself using effects everywhere, you’re probably fighting against Svelte rather than working with it.
Good uses for effects:
- Syncing with browser APIs (canvas, localStorage, clipboard)
- Integrating third-party libraries
- Setting up subscriptions (WebSockets, event listeners)
- Analytics and logging
- Direct DOM manipulation
Not good uses for effects (use $derived instead):
- Computing values from other values
- Transforming data
- Synchronizing one piece of state with another
We’ll explore this distinction in depth in the “When NOT to Use $effect” section.
When Effects Run: The Lifecycle
Understanding when effects run is essential for writing correct code. Let’s break down the complete lifecycle.
The Initial Run: After Mount
Effects run for the first time after the component has been mounted to the DOM. This is important—it means you can safely access DOM elements inside effects:
<script>
let canvas
let color = $state('#ff3e00')
let size = $state(50)
$effect(() => {
// `canvas` is guaranteed to exist here because
// this runs AFTER the component mounts
const context = canvas.getContext('2d')
context.clearRect(0, 0, canvas.width, canvas.height)
context.fillStyle = color
context.fillRect(0, 0, size, size)
})
</script>
<canvas bind:this={canvas} width="100" height="100"></canvas> Re-runs: In a Microtask
When reactive state changes, effects don’t run immediately. Instead, they’re scheduled to run in a microtask—a very short delay that allows multiple changes to be batched together.
<script>
let width = $state(100)
let height = $state(100)
$effect(() => {
console.log(`Dimensions: ${width} x ${height}`)
})
function resize() {
// These two changes happen in the same synchronous code block
width = 200
height = 200
// The effect does NOT run twice!
// It runs ONCE after both changes, logging "Dimensions: 200 x 200"
}
</script> Why batching matters:
- Performance: Without batching, changing 10 state variables would trigger 10 effect runs. With batching, it triggers just one.
- Consistency: Your effect always sees a consistent state. You never catch the state “in between” updates.
- DOM efficiency: DOM updates are also batched, so the browser only repaints once.
After DOM Updates
Effects run after any DOM updates have been applied. This means if you need to measure the DOM or interact with elements that were just added, they’ll be there:
<script>
let items = $state(['Apple', 'Banana'])
let list
$effect(() => {
// This runs AFTER the new item is in the DOM
console.log(`List has ${list.children.length} items`)
// If you just pushed 'Cherry', this will log 3, not 2
})
function addItem() {
items.push('Cherry')
}
</script>
<ul bind:this={list}>
{#each items as item}
<li>{item}</li>
{/each}
</ul> Server-Side Rendering: Effects Don’t Run
Effects Are Browser-Only
$effectnever runs during server-side rendering. This means browser-only APIs (document,localStorage,canvas, etc.) are always safe to use inside effects — they will never execute in a Node.js environment.
Effects only run in the browser. During server-side rendering (SSR), they’re completely skipped. This makes sense—there’s no DOM on the server, no canvas to draw on, no localStorage to write to.
<script>
$effect(() => {
// This code NEVER runs on the server
// Only in the browser after hydration
document.title = 'My App'
})
</script> This is actually a feature, not a limitation. It means you can safely use browser-only APIs inside effects without worrying about SSR errors.
Visual Timeline
Here’s how the effect lifecycle looks in practice:
Component Created
│
▼
┌─────────────────────────────┐
│ Initial Render (SSR) │ ← Effects DO NOT run
│ - HTML generated on server │
└─────────────────────────────┘
│
▼ (HTML sent to browser)
│
┌─────────────────────────────┐
│ Hydration │
│ - DOM is connected │
│ - Component mounts │
└─────────────────────────────┘
│
▼ (microtask)
│
┌─────────────────────────────┐
│ Effects Run (First Time) │ ← Dependencies recorded
└─────────────────────────────┘
│
▼ (user interacts, state changes)
│
┌─────────────────────────────┐
│ State Update Detected │
│ - Changes batched │
│ - DOM updated │
└─────────────────────────────┘
│
▼ (microtask)
│
┌─────────────────────────────┐
│ Teardown Runs (if exists) │ ← Cleanup from previous run
└─────────────────────────────┘
│
▼
┌─────────────────────────────┐
│ Effect Re-runs │ ← New dependencies recorded
└─────────────────────────────┘ Understanding Dependencies
What Gets Tracked
One of the most elegant aspects of $effect is that it automatically figures out what state it depends on. You don’t have to list dependencies like in React’s useEffect—Svelte tracks them at runtime.
The Basic Rule
When an effect runs, Svelte watches which reactive values (anything created with $state, $derived, or $props) are synchronously read. Those become the effect’s dependencies. When any of them change, the effect re-runs.
<script>
let firstName = $state('John')
let lastName = $state('Doe')
let age = $state(30)
$effect(() => {
// This effect reads `firstName` and `lastName`
// It does NOT read `age`
console.log(`Name: ${firstName} ${lastName}`)
})
</script>
<!-- Changing firstName or lastName will re-run the effect -->
<!-- Changing age will NOT re-run the effect --> Tracking Happens at Runtime
Dependencies are determined each time the effect runs, not at compile time. This enables powerful patterns with conditional logic:
<script>
let useFullName = $state(true)
let firstName = $state('John')
let lastName = $state('Doe')
let nickname = $state('Johnny')
$effect(() => {
if (useFullName) {
// When useFullName is true, we depend on firstName and lastName
console.log(`Full name: ${firstName} ${lastName}`)
} else {
// When useFullName is false, we depend on nickname instead
console.log(`Nickname: ${nickname}`)
}
})
</script> What happens:
- Initially,
useFullNameistrue, so the effect readsfirstNameandlastName - Dependencies:
useFullName,firstName,lastName - If you change
nickname, the effect does NOT re-run (it’s not a current dependency) - If you change
useFullNametofalse, the effect re-runs - Now it reads
nickname, so dependencies become:useFullName,nickname - Now changing
firstNamedoes NOT re-run the effect!
The Synchronous Requirement
Here’s a critical rule: only synchronously read values become dependencies. Anything read after an await or inside a setTimeout is NOT tracked.
<script>
let color = $state('#ff3e00')
let size = $state(50)
let canvas
$effect(() => {
const context = canvas.getContext('2d')
context.clearRect(0, 0, canvas.width, canvas.height)
// `color` is read synchronously → it IS a dependency
context.fillStyle = color
setTimeout(() => {
// `size` is read AFTER a delay → it is NOT a dependency
context.fillRect(0, 0, size, size)
}, 0)
})
</script> In this example:
- Changing
colorwill re-run the effect - PREFERRED because it’s read synchronously - Changing
sizewill NOT re-run the effect - AVOID because it’s read asynchronously
Why this design? It’s about predictability. Svelte can’t know what will happen in the future (after async operations complete), so it only tracks what it can observe synchronously.
Object vs Property Tracking
Svelte tracks at the property level, not the object level. This is nuanced and important:
<script>
let state = $state({ value: 0 })
let derived = $derived({ value: state.value * 2 })
// This effect reads the `state` OBJECT, not `state.value`
$effect(() => {
state // Just referencing the object
console.log('Effect 1 ran')
})
// This effect reads `state.value`
$effect(() => {
state.value // Reading the property
console.log('Effect 2 ran')
})
// This effect depends on `derived`, which is a NEW object each time
$effect(() => {
derived
console.log('Effect 3 ran')
})
</script>
<button onclick={() => state.value++}>Increment</button> When you click the button:
- Effect 1: Does NOT re-run (the
stateobject reference didn’t change, only a property inside it) - Effect 2: DOES re-run (it reads
state.value, which changed) - Effect 3: DOES re-run (
derivedis a new object each time because$derivedreturns a fresh object)
Tracking Through Function Calls
Dependencies are tracked even when values are accessed indirectly through functions:
<script>
let count = $state(0)
function getCount() {
return count // This reads `count`
}
$effect(() => {
// Even though we call a function, Svelte tracks that
// `count` was ultimately read
console.log(`Count via function: ${getCount()}`)
})
</script> This works because Svelte’s tracking happens at the moment of reading, regardless of how many function calls deep you are.
Opting Out: The untrack Function
Sometimes you want to read a value without creating a dependency. Use the untrack function:
<script>
import { untrack } from 'svelte'
let logPrefix = $state('[LOG]')
let count = $state(0)
$effect(() => {
// We want to re-run when `count` changes
// but NOT when `logPrefix` changes
const prefix = untrack(() => logPrefix)
console.log(`${prefix} Count: ${count}`)
})
</script> Now the effect only depends on count. Changing logPrefix won’t trigger a re-run.
Cleanup Patterns
The Teardown Function
Effects often set up things that need to be cleaned up: timers, subscriptions, event listeners, etc. The teardown function (or cleanup function) is how you handle this properly.
The Basic Pattern
An effect can return a function. This function runs:
- Immediately before the effect re-runs (to clean up from the previous run)
- When the component is destroyed (to clean up completely)
<script>
let count = $state(0)
let interval = $state(1000)
$effect(() => {
// This runs when the effect starts or re-runs
console.log(`Setting up interval: ${interval}ms`)
const id = setInterval(() => {
count++
}, interval)
// This TEARDOWN function runs before the next run or on destroy
return () => {
console.log(`Cleaning up interval: ${id}`)
clearInterval(id)
}
})
</script>
<p>Count: {count}</p>
<button onclick={() => (interval = interval / 2)}>Faster</button>
<button onclick={() => (interval = interval * 2)}>Slower</button> When you click “Faster”:
- The
intervalstate changes - Svelte schedules the effect to re-run
- The teardown function runs first:
clearInterval(id)cleans up the old interval - The effect body runs: a new interval is created with the new timing
Why Teardown Matters
Preventing Memory Leaks
Always Return a Cleanup FunctionEvery effect that creates a timer, opens a socket, registers an event listener, or starts an observer must return a teardown function. Omitting it leaks resources silently — each re-run adds a new listener without removing the old one.
Without proper cleanup, you’ll accumulate resources that were never released. Here’s a common scenario:
BAD: No cleanup
<script>
let isActive = $state(true)
$effect(() => {
if (isActive) {
// This adds a new listener every time `isActive` becomes true
// Old listeners are NEVER removed!
window.addEventListener('resize', handleResize)
}
})
function handleResize() {
console.log('Window resized')
}
</script> GOOD: Proper cleanup
<script>
let isActive = $state(true)
$effect(() => {
if (!isActive) return // Guard clause
function handleResize() {
console.log('Window resized')
}
window.addEventListener('resize', handleResize)
return () => {
// Clean up when isActive changes or component unmounts
window.removeEventListener('resize', handleResize)
}
})
</script> Teardown Timing Visualized
Initial Run:
┌──────────────────────────────────┐
│ Effect body runs │
│ → setInterval(fn, 1000) │
│ → Returns teardown function │
└──────────────────────────────────┘
State Changes (interval becomes 500):
┌──────────────────────────────────┐
│ 1. Teardown from previous run │
│ → clearInterval(oldId) │
├──────────────────────────────────┤
│ 2. Effect body runs again │
│ → setInterval(fn, 500) │
│ → Returns new teardown │
└──────────────────────────────────┘
Component Destroyed:
┌──────────────────────────────────┐
│ Teardown runs one final time │
│ → clearInterval(currentId) │
└──────────────────────────────────┘ Real-World Cleanup Examples
1: WebSocket Connection
<script>
let serverUrl = $state('wss://api.example.com')
let messages = $state([])
let status = $state('disconnected')
$effect(() => {
status = 'connecting'
const socket = new WebSocket(serverUrl)
socket.onopen = () => {
status = 'connected'
}
socket.onmessage = (event) => {
messages = [...messages, JSON.parse(event.data)]
}
socket.onerror = () => {
status = 'error'
}
socket.onclose = () => {
status = 'disconnected'
}
// CRITICAL: Clean up the connection
return () => {
if (socket.readyState === WebSocket.OPEN) {
socket.close()
}
}
})
</script>
<p>Status: {status}</p>
<input bind:value={serverUrl} /> When serverUrl changes, the old WebSocket is properly closed before opening a new one.
2: Animation Frame Loop
<script>
let isAnimating = $state(true)
let rotation = $state(0)
$effect(() => {
if (!isAnimating) return
let animationId
function animate() {
rotation = (rotation + 1) % 360
animationId = requestAnimationFrame(animate)
}
animationId = requestAnimationFrame(animate)
return () => {
// Stop the animation loop
cancelAnimationFrame(animationId)
}
})
</script>
<div style="transform: rotate({rotation}deg)">🌀</div>
<button onclick={() => (isAnimating = !isAnimating)}>
{isAnimating ? 'Stop' : 'Start'}
</button> 3: ResizeObserver
<script>
let container
let dimensions = $state({ width: 0, height: 0 })
$effect(() => {
if (!container) return
const observer = new ResizeObserver((entries) => {
const entry = entries[0]
dimensions = {
width: entry.contentRect.width,
height: entry.contentRect.height
}
})
observer.observe(container)
return () => {
observer.disconnect()
}
})
</script>
<div bind:this={container} style="resize: both; overflow: auto; border: 1px solid gray;">
<p>Width: {dimensions.width}px</p>
<p>Height: {dimensions.height}px</p>
</div> Conditional Cleanup
Sometimes you only need to clean up if a certain condition was met:
<script>
let useWebSocket = $state(false)
let socket = null
$effect(() => {
if (useWebSocket) {
socket = new WebSocket('wss://api.example.com')
return () => {
socket.close()
socket = null
}
}
// If useWebSocket is false, no cleanup needed
// because nothing was set up
})
</script> Avoiding Infinite Loops
The most common pitfall with effects is the infinite loop. It happens when an effect reads and writes to the same state, creating a cycle: change triggers effect, effect causes change, change triggers effect…
The Problem
<script>
let count = $state(0)
// AVOID: INFINITE LOOP!
$effect(() => {
count = count + 1 // Reads count, then writes to count
// This triggers the effect again... forever
})
</script> Why It Happens
- Effect runs, reads
count(value: 0) - Effect writes to
count(now: 1) - Svelte detects
countchanged - Effect is scheduled to re-run
- Effect runs, reads
count(value: 1) - Effect writes to
count(now: 2) - → Infinite loop
The Golden Rule
ImportantIf you’re writing to a
$stateinside an effect, and that effect also reads from that state, you’re probably doing it wrong.
Solutions
1: Use $derived Instead
Most “compute something from state” scenarios should use $derived, not $effect:
<script>
let items = $state([1, 2, 3, 4, 5])
// AVOID: Using effect to compute
let total = $state(0)
$effect(() => {
total = items.reduce((sum, n) => sum + n, 0) // Infinite loop potential!
})
// PREFERRED: Using derived
let total = $derived(items.reduce((sum, n) => sum + n, 0))
</script> 2: Use untrack for Read-Without-Dependency
If you need to read a value without making it a dependency:
<script>
import { untrack } from 'svelte'
let history = $state([])
let currentValue = $state(0)
$effect(() => {
// Read currentValue as a dependency (triggers on change)
const value = currentValue
// Read history without making it a dependency
const previousHistory = untrack(() => history)
// Now we can safely write to history
history = [...previousHistory, value]
})
</script> 3: Use Guard Conditions
Check if an update is actually needed before writing:
<script>
let searchTerm = $state('')
let normalizedTerm = $state('')
$effect(() => {
const normalized = searchTerm.trim().toLowerCase()
// Only update if the value actually changed
if (normalized !== normalizedTerm) {
normalizedTerm = normalized
}
})
</script> But honestly, this example should just use $derived:
<script>
let searchTerm = $state('')
let normalizedTerm = $derived(searchTerm.trim().toLowerCase())
</script> 4: Separate Read and Write States
If you truly need an effect that modifies state, make sure the state you read is different from the state you write:
<script>
let inputValue = $state('') // This is what we READ
let processedValue = $state('') // This is what we WRITE
$effect(() => {
// Read from inputValue
// Write to processedValue (different state)
processedValue = inputValue.toUpperCase()
})
</script>
<input bind:value={inputValue} /><p>Processed: {processedValue}</p> Again, this is better as a $derived:
<script>
let inputValue = $state('')
let processedValue = $derived(inputValue.toUpperCase())
</script> Detecting Infinite Loops
Svelte has built-in protection. If it detects an effect running too many times in quick succession, it will stop and throw an error:
Detected effect cycle During development, you’ll see this in your console along with a stack trace pointing to the problematic effect.
$effect vs $effect.pre: Timing Matters
Svelte provides two effect runes that differ in when they run relative to DOM updates.
$effect: After DOM Updates
Regular $effect runs after Svelte has applied changes to the DOM. This is what you want 90% of the time:
<script>
let items = $state(['a', 'b', 'c'])
let list
$effect(() => {
// The DOM is already updated here
console.log(`List has ${list.children.length} children`)
// If we just added an item, the new <li> is already there
})
</script>
<ul bind:this={list}>
{#each items as item}
<li>{item}</li>
{/each}
</ul> Use $effect when you need to:
- Read from the DOM after it’s updated
- Measure element sizes after content changes
- Apply post-render operations
- Sync with external libraries that work with the rendered DOM
$effect.pre: Before DOM Updates
$effect.pre runs before Svelte applies changes to the DOM. This is a more specialized tool for specific use cases:
<script>
import { tick } from 'svelte'
let messages = $state([])
let viewport
$effect.pre(() => {
if (!viewport) return
// Reference messages to make this a dependency
messages.length
// Check if we're scrolled to the bottom BEFORE new messages render
const isAtBottom = viewport.offsetHeight + viewport.scrollTop > viewport.scrollHeight - 20
if (isAtBottom) {
// Wait for DOM to update, then scroll
tick().then(() => {
viewport.scrollTo(0, viewport.scrollHeight)
})
}
})
</script>
<div bind:this={viewport} class="chat-viewport">
{#each messages as message}
<p>{message}</p>
{/each}
</div> Why $effect.pre is needed here:
- We need to check if the user is scrolled to the bottom before the new message is added
- If we used regular
$effect, the new message would already be in the DOM - Our scroll position calculation would be off because the scroll height changed
Side-by-Side Comparison
<script>
let count = $state(0)
let paragraph
$effect.pre(() => {
console.log('[PRE] Before DOM update')
console.log('[PRE] Paragraph text:', paragraph?.textContent)
})
$effect(() => {
console.log('[EFFECT] After DOM update')
console.log('[EFFECT] Paragraph text:', paragraph?.textContent)
})
</script>
<p bind:this={paragraph}>Count: {count}</p>
<button onclick={() => count++}>Increment</button> Click the button when count is 0:
[PRE] Before DOM update
[PRE] Paragraph text: Count: 0 ← Still shows old value!
[EFFECT] After DOM update
[EFFECT] Paragraph text: Count: 1 ← Shows new value When to Use Each
| Scenario | Use |
|---|---|
| Measuring DOM after updates | $effect |
| Canvas drawing | $effect |
| Third-party library integration | $effect |
| Analytics/logging | $effect |
| Scroll position preservation | $effect.pre |
| Pre-render calculations | $effect.pre |
| Comparing old vs new DOM | $effect.pre |
Practical Example: Auto-Scroll Chat
Here’s a complete example showing why $effect.pre is essential for a chat interface:
<script>
import { tick } from 'svelte'
let messages = $state([
{ id: 1, text: 'Hello!' },
{ id: 2, text: 'How are you?' }
])
let newMessage = $state('')
let chatContainer
let shouldAutoScroll = $state(true)
// Check scroll position BEFORE new content is added
$effect.pre(() => {
if (!chatContainer) return
// Track messages to run when they change
messages.length
// Check if user is at the bottom (within 50px)
const { scrollTop, scrollHeight, clientHeight } = chatContainer
shouldAutoScroll = scrollHeight - scrollTop - clientHeight < 50
})
// Scroll AFTER new content is rendered
$effect(() => {
if (!chatContainer || !shouldAutoScroll) return
// Track messages
messages.length
// Scroll to bottom
chatContainer.scrollTop = chatContainer.scrollHeight
})
function sendMessage() {
if (!newMessage.trim()) return
messages = [
...messages,
{
id: Date.now(),
text: newMessage
}
]
newMessage = ''
}
</script>
<div bind:this={chatContainer} class="chat-container">
{#each messages as msg (msg.id)}
<div class="message">{msg.text}</div>
{/each}
</div>
<form
onsubmit={(e) => {
e.preventDefault()
sendMessage()
}}
>
<input bind:value={newMessage} placeholder="Type a message..." />
<button type="submit">Send</button>
</form>
<style>
.chat-container {
height: 300px;
overflow-y: auto;
border: 1px solid #ccc;
padding: 1rem;
}
.message {
padding: 0.5rem;
margin: 0.25rem 0;
background: #f0f0f0;
border-radius: 4px;
}
</style> Advanced Effect Runes
Beyond $effect and $effect.pre, Svelte 5 provides additional tools for specific scenarios.
$effect.tracking()
This rune tells you whether code is running inside a tracking context (an effect or template). It’s useful for building advanced abstractions:
<script>
console.log('In component setup:', $effect.tracking()) // false
$effect(() => {
console.log('In effect:', $effect.tracking()) // true
})
</script>
<p>In template: {$effect.tracking()}</p> <!-- true --> Practical Use Case: Conditional Subscriptions
<script>
import { createSubscriber } from 'svelte/reactivity'
function createMousePosition() {
let x = $state(0)
let y = $state(0)
const subscribe = createSubscriber((update) => {
function handleMove(e) {
x = e.clientX
y = e.clientY
update()
}
window.addEventListener('mousemove', handleMove)
return () => {
window.removeEventListener('mousemove', handleMove)
}
})
return {
get x() {
subscribe() // Only subscribe if in a tracking context
return x
},
get y() {
subscribe()
return y
}
}
}
const mouse = createMousePosition()
</script>
<!-- Reading mouse.x in the template subscribes to updates --><p>Mouse: {mouse.x}, {mouse.y}</p> $effect.root()
This creates a non-tracked scope that doesn’t auto-cleanup. It’s for advanced use cases where you need manual control:
<script>
let count = $state(0)
// Create a root effect that we control manually
const cleanup = $effect.root(() => {
$effect(() => {
console.log(`Count is: ${count}`)
})
// Optional: return cleanup logic
return () => {
console.log('Root effect cleaned up')
}
})
// Later, we can manually destroy it
function stopWatching() {
cleanup()
}
</script>
<p>{count}</p>
<button onclick={() => count++}>Increment</button>
<button onclick={stopWatching}>Stop Watching</button> When to use $effect.root:
- Creating effects outside component initialization
- Building reusable effect-based utilities
- Manual lifecycle control
- Testing scenarios
When NOT to Use $effect
This section is crucial. Many developers new to Svelte 5 overuse effects because they’re familiar from other frameworks. But Svelte’s $derived is often the better choice.
Don't Use Effects for Pure ComputationsAsk yourself: “Am I computing a value from other values?”
If yes → use
$derived, If no (you’re syncing with something external) → use$effect
Anti-Pattern: Syncing State to State
<script>
let count = $state(0)
let doubled = $state()
// AVOID: This creates unnecessary state and effect
$effect(() => {
doubled = count * 2
})
</script> Why it’s bad:
- Extra state to manage
doubledis brieflyundefinedbefore the effect runs- More code, more complexity
- Svelte has a built-in solution
Do this instead:
<script>
let count = $state(0)
let doubled = $derived(count * 2)
</script> Anti-Pattern: The Sync Two Inputs Problem
A classic mistake is trying to keep two inputs in sync:
<script>
const total = 100
let spent = $state(0)
let remaining = $state(total)
// AVOID: This creates TWO effects that trigger each other!
$effect(() => {
remaining = total - spent
})
$effect(() => {
spent = total - remaining
})
</script>
<label>
Spent: <input type="number" bind:value={spent} max={total} />
</label>
<label>
Remaining: <input type="number" bind:value={remaining} max={total} />
</label> Why it’s bad:
- Circular dependency between effects
- Hard to reason about which runs when
- Can cause unexpected behavior
Solution 1: Derived + Callback
<script>
const total = 100
let spent = $state(0)
let remaining = $derived(total - spent)
function updateFromRemaining(newRemaining) {
spent = total - newRemaining
}
</script>
<label>
Spent: <input type="number" bind:value={spent} max={total} />
</label>
<label>
Remaining:
<input
type="number"
value={remaining}
oninput={(e) => updateFromRemaining(+e.target.value)}
max={total}
/>
</label> Solution 2: Function Bindings (Svelte 5.7+)
<script>
const total = 100
let spent = $state(0)
let remaining = $derived(total - spent)
</script>
<label>
Spent: <input type="number" bind:value={spent} max={total} />
</label>
<label>
Remaining:
<input type="number" bind:value={() => remaining, (v) => (spent = total - v)} max={total} />
</label> Good Use Cases for $effect
1: Canvas Drawing
<script>
let canvas
let hue = $state(0)
$effect(() => {
const ctx = canvas.getContext('2d')
ctx.fillStyle = `hsl(${hue}, 70%, 50%)`
ctx.fillRect(0, 0, canvas.width, canvas.height)
})
</script>
<canvas bind:this={canvas} width="200" height="200"></canvas>
<input type="range" bind:value={hue} min="0" max="360" /> 2: Document Title
<script>
let unreadCount = $state(5)
$effect(() => {
document.title = unreadCount > 0 ? `(${unreadCount}) My App` : 'My App'
})
</script> 3. LocalStorage Sync
<script>
let theme = $state('light')
// Load from localStorage on mount
$effect(() => {
const saved = localStorage.getItem('theme')
if (saved) theme = saved
})
// Save to localStorage on change
$effect(() => {
localStorage.setItem('theme', theme)
})
</script> 4. Third-Party Library Integration
<script>
import Chart from 'chart.js/auto'
let data = $state([10, 20, 30, 40])
let canvas
let chart
$effect(() => {
if (chart) {
// Update existing chart
chart.data.datasets[0].data = data
chart.update()
} else {
// Create new chart
chart = new Chart(canvas, {
type: 'bar',
data: {
labels: ['A', 'B', 'C', 'D'],
datasets: [{ data }]
}
})
}
return () => {
chart?.destroy()
chart = null
}
})
</script>
<canvas bind:this={canvas}></canvas> 5: Event Listeners on External Elements
<script>
let isEnabled = $state(true)
let keyPressed = $state('')
$effect(() => {
if (!isEnabled) return
function handleKeyDown(e) {
keyPressed = e.key
}
document.addEventListener('keydown', handleKeyDown)
return () => {
document.removeEventListener('keydown', handleKeyDown)
}
})
</script>
<p>Last key: {keyPressed}</p>
<button onclick={() => (isEnabled = !isEnabled)}>
{isEnabled ? 'Disable' : 'Enable'} listener
</button> Practical Examples
Let’s build some real-world components that demonstrate effect patterns correctly.
1: Debounced Search
A search input that waits for the user to stop typing before searching:
<script>
let searchTerm = $state('')
let debouncedTerm = $state('')
let results = $state([])
let isSearching = $state(false)
// Debounce the search term
$effect(() => {
const term = searchTerm // Read the reactive value
const timeoutId = setTimeout(() => {
debouncedTerm = term
}, 300)
return () => clearTimeout(timeoutId)
})
// Perform the search when debounced term changes
$effect(() => {
const term = debouncedTerm
if (!term) {
results = []
return
}
isSearching = true
// Simulate API call
const controller = new AbortController()
fetch(`/api/search?q=${encodeURIComponent(term)}`, {
signal: controller.signal
})
.then((r) => r.json())
.then((data) => {
results = data
isSearching = false
})
.catch((err) => {
if (err.name !== 'AbortError') {
console.error(err)
isSearching = false
}
})
return () => controller.abort()
})
</script>
<input bind:value={searchTerm} placeholder="Search..." />
{#if isSearching}
<p>Searching...</p>
{:else if results.length > 0}
<ul>
{#each results as result}
<li>{result.title}</li>
{/each}
</ul>
{:else if debouncedTerm}
<p>No results found</p>
{/if} 2: Intersection Observer
Track when elements enter and leave the viewport:
<script>
let items = $state(
Array.from({ length: 20 }, (_, i) => ({
id: i,
visible: false
}))
)
function createVisibilityObserver(element, index) {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
items[index].visible = entry.isIntersecting
})
},
{ threshold: 0.5 }
)
observer.observe(element)
return () => observer.disconnect()
}
</script>
<div class="scroll-container">
{#each items as item, index (item.id)}
{@const visible = item.visible}
<div class="item" class:visible use:action={(node) => createVisibilityObserver(node, index)}>
Item {item.id}
{visible ? '👁️' : ''}
</div>
{/each}
</div>
<style>
.scroll-container {
height: 400px;
overflow-y: auto;
}
.item {
height: 100px;
margin: 10px;
padding: 20px;
background: #f0f0f0;
transition: background 0.3s;
}
.item.visible {
background: #d0ffd0;
}
</style> 3: Media Query Listener
React to responsive breakpoints:
<script>
let breakpoint = $state('desktop')
$effect(() => {
const queries = {
mobile: window.matchMedia('(max-width: 639px)'),
tablet: window.matchMedia('(min-width: 640px) and (max-width: 1023px)'),
desktop: window.matchMedia('(min-width: 1024px)')
}
function updateBreakpoint() {
if (queries.mobile.matches) breakpoint = 'mobile'
else if (queries.tablet.matches) breakpoint = 'tablet'
else breakpoint = 'desktop'
}
// Initial check
updateBreakpoint()
// Listen for changes
Object.values(queries).forEach((mq) => {
mq.addEventListener('change', updateBreakpoint)
})
return () => {
Object.values(queries).forEach((mq) => {
mq.removeEventListener('change', updateBreakpoint)
})
}
})
</script>
<p>Current breakpoint: <strong>{breakpoint}</strong></p>
{#if breakpoint === 'mobile'}
<MobileNav />
{:else}
<DesktopNav />
{/if} 4: Undo/Redo History
Track state changes and allow undo/redo:
<script>
let text = $state('')
let history = $state([''])
let historyIndex = $state(0)
let isUndoRedo = false
// Track text changes for undo/redo
$effect(() => {
const currentText = text
// Don't record if this change came from undo/redo
if (isUndoRedo) {
isUndoRedo = false
return
}
// Don't record if text hasn't changed
if (currentText === history[historyIndex]) return
// Truncate any "future" history and add new entry
history = [...history.slice(0, historyIndex + 1), currentText]
historyIndex = history.length - 1
})
function undo() {
if (historyIndex > 0) {
isUndoRedo = true
historyIndex--
text = history[historyIndex]
}
}
function redo() {
if (historyIndex < history.length - 1) {
isUndoRedo = true
historyIndex++
text = history[historyIndex]
}
}
let canUndo = $derived(historyIndex > 0)
let canRedo = $derived(historyIndex < history.length - 1)
</script>
<textarea bind:value={text} rows="5" cols="40"></textarea>
<div class="controls">
<button onclick={undo} disabled={!canUndo}> ↩ Undo </button>
<button onclick={redo} disabled={!canRedo}> ↪ Redo </button>
<span>
Step {historyIndex + 1} of {history.length}
</span>
</div> 5: Real-Time Clock with Timezone
<script>
let timezone = $state('local')
let time = $state(new Date())
$effect(() => {
// Update every second
const intervalId = setInterval(() => {
time = new Date()
}, 1000)
return () => clearInterval(intervalId)
})
let formattedTime = $derived.by(() => {
const options = {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: true
}
if (timezone !== 'local') {
options.timeZone = timezone
}
return time.toLocaleTimeString('en-US', options)
})
const timezones = [
{ value: 'local', label: 'Local Time' },
{ value: 'America/New_York', label: 'New York' },
{ value: 'Europe/London', label: 'London' },
{ value: 'Asia/Tokyo', label: 'Tokyo' },
{ value: 'Australia/Sydney', label: 'Sydney' }
]
</script>
<select bind:value={timezone}>
{#each timezones as tz}
<option value={tz.value}>{tz.label}</option>
{/each}
</select>
<p class="time">{formattedTime}</p>
<style>
.time {
font-size: 3rem;
font-family: monospace;
}
</style> Common Pitfalls and How to Fix Them
1: Effect Runs Before DOM is Ready
The Problem:
<script>
let element
$effect(() => {
// element might be undefined here!
element.focus()
})
</script>
<input bind:this={element} /> The Fix: Guard against undefined
<script>
let element
$effect(() => {
if (!element) return // Guard clause
element.focus()
})
</script> 2: Reading Async Values
The Problem:
<script>
let userId = $state(1);
let userName = $state('');
$effect(() => {
// AVOID: After the await, Svelte stops tracking
const response = await fetch(`/api/users/${userId}`);
const user = await response.json();
userName = user.name; // userId is tracked, but...
});
</script> The Fix: Read sync, then fetch
<script>
let userId = $state(1)
let userName = $state('')
$effect(() => {
// PREFERRED: Read userId synchronously (it becomes a dependency)
const id = userId
// Then do async work
fetch(`/api/users/${id}`)
.then((r) => r.json())
.then((user) => {
userName = user.name
})
})
</script> 3: Multiple Effects When One Would Do
The Problem:
<script>
let x = $state(0)
let y = $state(0)
// AVOID: Two separate effects for related work
$effect(() => {
console.log('X changed:', x)
})
$effect(() => {
console.log('Y changed:', y)
})
</script> The Fix: Combine when appropriate
<script>
let x = $state(0)
let y = $state(0)
// PREFERRED: One effect that tracks both
$effect(() => {
console.log('Coordinates changed:', x, y)
})
</script> 4: Not Cleaning Up Subscriptions
The Problem:
<script>
let topic = $state('news')
$effect(() => {
// AVOID: Never cleaned up! When topic changes, old subscription remains
socket.subscribe(topic, handleMessage)
})
</script> The Fix: Return a cleanup function
<script>
let topic = $state('news')
$effect(() => {
socket.subscribe(topic, handleMessage)
// PREFERRED: Clean up before re-subscribing
return () => {
socket.unsubscribe(topic, handleMessage)
}
})
</script> 5: Effect Inside a Loop
The Problem:
<script>
let items = $state([{ id: 1 }, { id: 2 }])
// AVOID: This runs once when items changes, NOT per item
$effect(() => {
items.forEach((item) => {
console.log('Processing:', item.id)
})
})
</script> The Fix: Use component per item or track specific properties
<!-- PREFERRED: Each item gets its own component with its own effects -->
{#each items as item (item.id)}
<ItemComponent data={item} />
{/each} onMount vs $effect for Setup Code
onMount remains fully supported in Svelte 5 and is often the clearer choice for one-time setup that doesn’t depend on reactive state.
Use onMount when:
- Setup runs once and doesn’t depend on reactive values
- You want explicit “run on mount” semantics
- The code is easier to understand as a lifecycle event
<script>
import { onMount } from 'svelte'
// PREFERRED: one-time setup, no dependencies
onMount(() => {
const interval = setInterval(doSomething, 1000)
return () => clearInterval(interval)
})
</script> Use $effect when:
- The setup depends on reactive state and should re-run when it changes
- You need the behavior to react to state changes
<script>
let intervalMs = $state(1000)
// PREFERRED: re-runs when intervalMs changes
$effect(() => {
const interval = setInterval(doSomething, intervalMs)
return () => clearInterval(interval)
})
</script> The key difference: onMount runs exactly once. $effect runs on mount and re-runs when dependencies change. Choose based on whether you need reactivity, not based on which “feels more Svelte 5.”
Quick Reference
Effect Types
| Rune | When It Runs | Use Case |
|---|---|---|
$effect | After DOM updates | Canvas, external libs, measurements |
$effect.pre | Before DOM updates | Scroll position, pre-render calculations |
$effect.root | Manual control | Effects outside components, testing |
Cleanup Pattern
$effect(() => {
// Setup code here
const resource = createResource();
// Return cleanup function
return () => {
resource.destroy();
};
}); Dependency Tracking
import { untrack } from 'svelte';
$effect(() => {
// This IS tracked (effect runs when `tracked` changes)
const value = tracked;
// This is NOT tracked
const other = untrack(() => untracked);
}); Common Mistakes to Avoid
| ❌ Don’t | ✅ Do |
|---|---|
| Compute derived values in effects | Use $derived |
| Forget cleanup functions | Always clean up timers, listeners, subscriptions |
| Write to state you’re reading | Use untrack or separate states |
| Use effects for state synchronization | Use $derived or callbacks |
| Ignore the “effects are escape hatches” advice | Prefer declarative solutions |
Decision Tree
Need to compute something from state?
├── Yes → Use $derived
└── No → Need to sync with external system?
├── Yes → Use $effect
│ ├── Need to run before DOM? → Use $effect.pre
│ └── Need cleanup? → Return a function
└── No → You probably don't need an effect Key Takeaways
Effects are for external synchronization, not computing values. Use
$derivedfor computations.Dependencies are tracked automatically by reading reactive values synchronously.
Always provide cleanup when setting up timers, listeners, or subscriptions.
Avoid infinite loops by not writing to state you’re reading (or use
untrack).Use
$effect.preonly when you need to read DOM state before updates.Effects run after mount and after DOM updates—you can safely access elements.
Effects don’t run on the server—browser-only APIs are safe to use.
Prefer
onMountfor one-time setup without reactive dependencies; use$effectwhen setup needs to react to state changes.
Master these patterns, and you’ll build Svelte 5 applications that are clean, efficient, and free of the subtle bugs that plague effect-heavy codebases.
See Also
Official Documentation
- Svelte 5: $effect - Official reference
- Runes Tutorial - Interactive tutorial
- Svelte REPL - Try code online
- Migration Guide - Svelte 4 to 5
External Resources
- MDN: AbortController - Canceling async operations
- Svelte Society - Community resources
- Svelte Discord - Get help