Svelte 5 Runes Deep Dive | Reactivity Rebuilt from Scratch
Key takeaways
Svelte 4 reactivity relied on let declarations and $: labels inside .svelte files, which was easy to start with but hard to reason about. Runes make signals explicit; the post shows each rune, why $effect cleanup matters for async work, and how to move shared state into .svelte.ts modules.
Why Runes?
Svelte 4’s reactivity had limitations:
<!-- Svelte 4 — works in .svelte files only -->
<script>
let count = 0 // reactive variable (implicit)
$: doubled = count * 2 // reactive statement (implicit)
$: if (count > 10) console.log('high') // reactive side effect
// Problem: doesn't work if count is reassigned inside a callback
function reset() {
// count = 0 ✅ reactive
// But setTimeout(() => count = 0) ⚠️ NOT reactive in some cases
}
</script>
The general shape of the problem the comment is pointing at is real, even in cases less obvious than this one: Svelte 4’s reactivity is a compile-time transform that watches for assignment expressions it can see syntactically in your component’s script and inserts invalidation logic around them. That works well for the common cases, but it’s fundamentally a static analysis, not a runtime tracking system — which is why reactivity in Svelte 4 gets fragile in exactly the situations that involve indirection: reassigning a variable from inside a function defined in another module, or reactivity depending on object identity vs. mutation in ways that aren’t always obvious from reading the component. Runes replace that compile-time guesswork with an actual runtime signal system — $state creates a reactive cell that notifies its readers whenever it changes, regardless of which function or file the assignment happens in, because reactivity is now a property of the value itself rather than something inferred from where an assignment textually appears in a .svelte file.
Svelte 5 runes are explicit signals — predictable everywhere:
<!-- Svelte 5 — explicit, works everywhere -->
<script>
let count = $state(0)
let doubled = $derived(count * 2)
$effect(() => { if (count > 10) console.log('high') })
</script>
$state — Reactive State
<script>
// Primitive state
let count = $state(0)
let name = $state('Alice')
let isOpen = $state(false)
// Object state — deep reactive (every property is reactive)
let user = $state({
name: 'Alice',
age: 30,
preferences: { theme: 'dark' },
})
// Array state — methods like push/pop are reactive
let todos = $state([
{ id: 1, text: 'Learn runes', done: false },
])
function addTodo(text) {
todos.push({ id: Date.now(), text, done: false }) // reactive!
}
function toggleTodo(id) {
const todo = todos.find(t => t.id === id)
if (todo) todo.done = !todo.done // reactive mutation!
}
// Update nested property
function updateTheme(theme) {
user.preferences.theme = theme // reactive!
}
</script>
<button onclick={() => count++}>{count}</button>
<p>Hello, {user.name} (age {user.age})</p>
{#each todos as todo}
<label>
<input type="checkbox" checked={todo.done} onchange={() => toggleTodo(todo.id)} />
{todo.text}
</label>
{/each}
$state.raw — non-deep reactive
<script>
// Only the assignment is reactive, not mutations
// Useful for large objects you replace wholesale
let items = $state.raw([1, 2, 3, 4, 5])
function shuffle() {
items = [...items].sort(() => Math.random() - 0.5) // reactive (reassignment)
// items.push(6) // NOT reactive — raw state doesn't track mutations
}
</script>
The name “raw” is doing real communicative work here — it tells you this $state skips the Proxy-wrapping machinery that gives deep-reactive $state its ability to detect nested mutations. That’s not a limitation to work around so much as an intentional trade for large data you always replace wholesale rather than poke at field by field: proxying every element of a 10,000-item array on every access has a real cost, and if your code only ever does items = newArray (never items[3].foo = x), you’re paying for tracking granularity you never use. The failure mode to watch for is exactly what the comment shows — items.push(6) compiles without error and silently does nothing reactive, which is a quiet bug rather than a loud one if you reach for .raw out of habit on state you do mutate in place.
$derived — Computed Values
<script>
let items = $state([
{ name: 'Apple', price: 1.5, category: 'fruit' },
{ name: 'Bread', price: 2.0, category: 'bakery' },
{ name: 'Banana', price: 0.75, category: 'fruit' },
])
let filter = $state('all')
let sortBy = $state('name')
// Simple derived
let total = $derived(items.reduce((sum, item) => sum + item.price, 0))
// Derived from derived
let filtered = $derived(
filter === 'all' ? items : items.filter(i => i.category === filter)
)
let sorted = $derived(
[...filtered].sort((a, b) => a[sortBy] < b[sortBy] ? -1 : 1)
)
let itemCount = $derived(filtered.length)
// $derived.by for complex logic
let stats = $derived.by(() => {
const prices = items.map(i => i.price)
return {
min: Math.min(...prices),
max: Math.max(...prices),
avg: prices.reduce((s, p) => s + p, 0) / prices.length,
count: items.length,
}
})
</script>
<p>Showing {itemCount} items, total: ${total.toFixed(2)}</p>
<p>Price range: ${stats.min} - ${stats.max}, avg: ${stats.avg.toFixed(2)}</p>
{#each sorted as item}
<div>{item.name} - ${item.price}</div>
{/each}
The chain filtered → sorted → the template is worth tracing once to see why layered $derived values don’t recompute more than necessary. Changing sortBy only invalidates sorted (which reads sortBy) — filtered never reads sortBy, so it’s untouched, and total/stats (which read items directly, not filtered) are untouched too. Each $derived only recomputes when something it actually reads changes, so layering computations like this doesn’t cost you redundant recalculation the way it might if you’d memoized the whole pipeline as one big useMemo with an overly broad dependency array in a comparable React component.
$effect — Side Effects
<script>
let query = $state('')
let results = $state([])
let loading = $state(false)
// Runs when dependencies change, cleans up before re-run
$effect(() => {
if (!query) {
results = []
return
}
loading = true
const controller = new AbortController()
fetch(`/api/search?q=${query}`, { signal: controller.signal })
.then(r => r.json())
.then(data => { results = data; loading = false })
.catch(err => { if (err.name !== 'AbortError') loading = false })
// Cleanup: runs before next effect and on destroy
return () => {
controller.abort()
}
})
// Effect that only runs once (on mount)
$effect(() => {
const handler = (e) => console.log('key:', e.key)
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
})
// $effect.pre — runs before DOM update (like useLayoutEffect)
$effect.pre(() => {
console.log('before DOM update, count is:', count)
})
</script>
The search effect is the one worth studying carefully, because it demonstrates the real reason $effect cleanup exists: without the AbortController, typing “hello” fast enough to trigger five fetches in quick succession means five in-flight requests racing each other, and there’s no guarantee the response for “h” doesn’t arrive after the response for “hello”, overwriting your correct results with stale data for a single character. The cleanup function returned from the effect runs automatically before the next invocation of that same effect — so every keystroke aborts whatever request the previous keystroke started, before starting a new one. Skipping this is one of the most common sources of “search results flicker to the wrong thing” bugs in any framework, not just Svelte.
Don’t overuse $effect
<script>
let a = $state(1)
let b = $state(2)
// ❌ Don't use $effect for derived values
let sum = $state(0)
$effect(() => { sum = a + b }) // wrong pattern
// ✅ Use $derived instead
let sum2 = $derived(a + b)
</script>
This antipattern deserves the callout section it gets, because it’s the single most common mistake people carry over from React’s useEffect-for-everything habits. The $effect version isn’t just more verbose — it’s a genuinely worse contract: sum becomes a second piece of state that can, even briefly, be out of sync with a and b (there’s a moment where a or b has changed but the effect hasn’t re-run yet), and it opens the door to accidentally creating a cycle if some other code reads and writes sum too. $derived has no such window — sum2 simply is a + b at every point in time, computed on read, with no separate state to drift out of sync. The rule of thumb: if a value can be computed purely from other reactive values, it should be $derived; reserve $effect for things that reach outside the reactive graph entirely — DOM APIs, network requests, subscriptions, logging.
$props — Component Props
<!-- Button.svelte -->
<script>
// Destructure props with defaults
let {
children,
variant = 'primary',
size = 'md',
disabled = false,
onclick,
class: className = '', // rename reserved word
...rest // spread remaining props
} = $props()
</script>
<button
class="btn btn-{variant} btn-{size} {className}"
{disabled}
{...rest}
{onclick}
>
{@render children()}
</button>
<!-- Usage -->
<Button variant="secondary" size="lg" onclick={() => console.log('clicked')}>
Click Me
</Button>
The class: className = '' rename is a small detail that trips up a lot of people migrating from other frameworks: class is a reserved word in JavaScript destructuring contexts in some situations and, more importantly, class as a prop name collides with the way Svelte handles the class attribute specially in some contexts — renaming it during destructuring (class: className) sidesteps the ambiguity entirely and is the idiomatic pattern for any component that wants to accept a class prop and merge it with its own internal classes, as shown in the class="btn ... {className}" line here.
$bindable — Two-Way Binding Props
<!-- Input.svelte -->
<script>
let {
value = $bindable(''), // bindable — parent can use bind:value
label = '',
type = 'text',
} = $props()
</script>
<label>
{label}
<input {type} bind:value />
</label>
<!-- Parent.svelte -->
<script>
import Input from './Input.svelte'
let name = $state('')
</script>
<!-- Two-way binding works because value is $bindable -->
<Input label="Name" bind:value={name} />
<p>Hello, {name}!</p>
Compare this to $props without $bindable: an ordinary prop is a one-way street — Parent.svelte passing value={name} to Input gives Input a snapshot to read, and Input mutating its local value would never flow back up, exactly like passing a value (not a reference) to a function in most languages. $bindable('') opts that specific prop into a two-way channel, and it’s deliberately opt-in per-prop rather than a global setting — reading Input’s Props interface (or here, its destructuring) tells you at a glance which props the component can write back into the parent’s state and which are strictly read-only, which matters a lot when you’re auditing a component you didn’t write for surprising side effects on parent state.
Snippets — Reusable Template Fragments
Snippets replace slot patterns with a more composable approach:
<!-- Card.svelte -->
<script>
let { header, children, footer } = $props()
</script>
<div class="card">
<div class="card-header">
{@render header?.()}
</div>
<div class="card-body">
{@render children()}
</div>
{#if footer}
<div class="card-footer">
{@render footer()}
</div>
{/if}
</div>
<!-- Usage -->
<Card>
{#snippet header()}
<h2>My Card</h2>
{/snippet}
<p>Card content goes here.</p>
{#snippet footer()}
<button>Action</button>
{/snippet}
</Card>
<!-- Inline snippet (reusable template) -->
{#snippet userRow(user)}
<tr>
<td>{user.name}</td>
<td>{user.email}</td>
<td>{user.role}</td>
</tr>
{/snippet}
<table>
{#each users as user}
{@render userRow(user)}
{/each}
</table>
The inline {#snippet userRow(user)} at the bottom is the case that has no real Svelte 4 equivalent at all — slots were always tied to a component boundary (you passed content into a component via its slot), but a snippet declared directly in a template is just a parameterized, reusable chunk of markup you can render repeatedly in a loop, without extracting a separate component. For something as small as a table row, spinning up a whole new .svelte file (component, props interface, import) is often more ceremony than the row template deserves — an inline snippet gets you the reuse without leaving the file, and {@render header?.()} in the Card example shows the other half of the picture: the ?. guards against a snippet prop that wasn’t passed, so an optional snippet can simply not render anything rather than throwing.
Universal Reactivity — $state in .ts files
The biggest Svelte 5 feature: reactivity outside .svelte files.
// store/counter.svelte.ts
export class Counter {
count = $state(0)
doubled = $derived(this.count * 2)
increment() { this.count++ }
decrement() { this.count-- }
reset() { this.count = 0 }
}
export const counter = new Counter()
<!-- App.svelte -->
<script>
import { counter } from './store/counter.svelte.ts'
</script>
<button onclick={() => counter.increment()}>+</button>
<p>{counter.count} (×2 = {counter.doubled})</p>
<button onclick={() => counter.decrement()}>-</button>
Using $state as a class field, as Counter does here, only works because of how .svelte.ts files are compiled — the Svelte compiler processes class field initializers the same way it processes let declarations, turning count = $state(0) into a genuinely reactive instance property. This is a meaningfully different pattern from Svelte 4’s store API (writable(0)), where you had to explicitly .subscribe() or use the $ auto-subscription syntax in .svelte files to read a store’s current value — here, counter.count is just a normal property read, no special syntax, no subscription management, and it works identically whether you’re reading it from a .svelte component or another .ts module.
Shared state pattern
// store/auth.svelte.ts
function createAuthStore() {
let user = $state<User | null>(null)
let loading = $state(false)
let isAuthenticated = $derived(user !== null)
async function login(email: string, password: string) {
loading = true
try {
user = await authApi.login(email, password)
} finally {
loading = false
}
}
function logout() {
user = null
}
return {
get user() { return user },
get loading() { return loading },
get isAuthenticated() { return isAuthenticated },
login,
logout,
}
}
export const auth = createAuthStore()
<script>
import { auth } from '$lib/store/auth.svelte.ts'
</script>
{#if auth.isAuthenticated}
<p>Welcome, {auth.user?.name}!</p>
<button onclick={() => auth.logout()}>Logout</button>
{:else}
<button onclick={() => auth.login('[email protected]', 'password')}>
Login
</button>
{/if}
The get user() / get loading() accessors in the returned object aren’t optional boilerplate — they’re the entire reason this pattern gives you live, reactive values instead of a frozen snapshot. If createAuthStore instead returned a plain object literal { user, loading, isAuthenticated, login, logout }, every consumer would capture whatever user and loading happened to be at the moment createAuthStore() ran — a one-time value, not a live reference, because JavaScript object properties copy values, not bindings. Getters defer the read: every time auth.user is accessed anywhere in your app, it re-evaluates return user against the closure’s current user variable, which is what makes {#if auth.isAuthenticated} in the template correctly react to login() and logout() changing that closed-over state.
Migrating from Svelte 4
# Automated migration
npx sv migrate svelte-5
<!-- Svelte 4 → Svelte 5 -->
<!-- State -->
let count = 0 → let count = $state(0)
<!-- Reactive statements -->
$: doubled = count * 2 → let doubled = $derived(count * 2)
$: { doSomething() } → $effect(() => { doSomething() })
<!-- Props -->
export let value → let { value } = $props()
export let value = 'x' → let { value = 'x' } = $props()
<!-- Events (Svelte 5 uses DOM events) -->
on:click={handler} → onclick={handler}
on:submit|preventDefault={handler} → onsubmit={(e) => { e.preventDefault(); handler(e) }}
<!-- Stores (still work, but can use $state instead) -->
import { writable } from 'svelte/store'
const count = writable(0)
$count → use $state in .svelte.ts files instead
Runes and their Svelte 4 equivalents
| Rune | Purpose | Svelte 4 equivalent |
|---|---|---|
$state() | Reactive variable | let x = value |
$state.raw() | Non-deep reactive | let x = value |
$derived() | Computed value | $: x = expr |
$derived.by() | Complex computed | $: { ... x = ... } |
$effect() | Side effect | $: { sideEffect() } |
$effect.pre() | Before DOM update | beforeUpdate() |
$props() | Component props | export let prop |
$bindable() | Two-way prop | export let prop + parent bind |
Related Articles
- Svelte 5 Runes
- React Hooks Pitfalls: Stale Closures, Effect Loops, Fetch Races and Useless Memoization
- What TypeScript 5 Changed