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.

REMEMBER

In 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 Tip

Effects 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 setInterval timer
  • 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:

  1. When the component first mounts, the effect runs once, logging “The count is now: 0”
  2. Each time you click the button, count changes
  3. Svelte detects that the effect depends on count
  4. 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:

  1. Performance: Without batching, changing 10 state variables would trigger 10 effect runs. With batching, it triggers just one.
  2. Consistency: Your effect always sees a consistent state. You never catch the state “in between” updates.
  3. 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

$effect never 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:

  1. Initially, useFullName is true, so the effect reads firstName and lastName
  2. Dependencies: useFullName, firstName, lastName
  3. If you change nickname, the effect does NOT re-run (it’s not a current dependency)
  4. If you change useFullName to false, the effect re-runs
  5. Now it reads nickname, so dependencies become: useFullName, nickname
  6. Now changing firstName does 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 color will re-run the effect - PREFERRED because it’s read synchronously
  • Changing size will 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 state object 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 (derived is a new object each time because $derived returns 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:

  1. Immediately before the effect re-runs (to clean up from the previous run)
  2. 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”:

  1. The interval state changes
  2. Svelte schedules the effect to re-run
  3. The teardown function runs first: clearInterval(id) cleans up the old interval
  4. The effect body runs: a new interval is created with the new timing

Why Teardown Matters

Preventing Memory Leaks

Always Return a Cleanup Function

Every 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

  1. Effect runs, reads count (value: 0)
  2. Effect writes to count (now: 1)
  3. Svelte detects count changed
  4. Effect is scheduled to re-run
  5. Effect runs, reads count (value: 1)
  6. Effect writes to count (now: 2)
  7. → Infinite loop

The Golden Rule

Important

If you’re writing to a $state inside 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:

  1. We need to check if the user is scrolled to the bottom before the new message is added
  2. If we used regular $effect, the new message would already be in the DOM
  3. 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

ScenarioUse
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 Computations

Ask 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:

  1. Extra state to manage
  2. doubled is briefly undefined before the effect runs
  3. More code, more complexity
  4. 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:

  1. Circular dependency between effects
  2. Hard to reason about which runs when
  3. 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.

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

RuneWhen It RunsUse Case
$effectAfter DOM updatesCanvas, external libs, measurements
$effect.preBefore DOM updatesScroll position, pre-render calculations
$effect.rootManual controlEffects 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 effectsUse $derived
Forget cleanup functionsAlways clean up timers, listeners, subscriptions
Write to state you’re readingUse untrack or separate states
Use effects for state synchronizationUse $derived or callbacks
Ignore the “effects are escape hatches” advicePrefer 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

  1. Effects are for external synchronization, not computing values. Use $derived for computations.

  2. Dependencies are tracked automatically by reading reactive values synchronously.

  3. Always provide cleanup when setting up timers, listeners, or subscriptions.

  4. Avoid infinite loops by not writing to state you’re reading (or use untrack).

  5. Use $effect.pre only when you need to read DOM state before updates.

  6. Effects run after mount and after DOM updates—you can safely access elements.

  7. Effects don’t run on the server—browser-only APIs are safe to use.

  8. Prefer onMount for one-time setup without reactive dependencies; use $effect when 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

External Resources