Svelte 5 Runes: Complete Guide 2026
Svelte 5 shipped in October 2024 with a completely new reactivity system. Instead of Svelte 4's compiler-detected reactive declarations, Svelte 5 introduces runes — explicit reactive primitives that work in both .svelte files and plain .svelte.ts files. The result is a more predictable, more composable, and more powerful reactivity model.
If you're starting a Svelte project today, you're using runes. If you're on Svelte 4, this guide covers everything you need to know to migrate.
TL;DR
Svelte 5 runes are explicit reactive primitives ($state, $derived, $effect, $props) that replace Svelte 4's implicit reactive declarations. They're compiler directives — not function calls — so the $ prefix is not a dollar sign convention, it's Svelte's syntax for reactive state. The biggest wins: reactive state works outside .svelte files, $derived is always-consistent, and $effect runs after DOM updates with proper cleanup semantics.
Key Takeaways
$state(value): reactive state — replaceslet count = 0in Svelte 4$derived(expr): computed values — replaces$: doubled = count * 2$effect(() => {}): side effects with auto-cleanup — replaces$: { sideEffect() }$props(): component props — replacesexport let name$bindable(): marks a prop as two-way bindable$inspect(value): dev-only reactive logging — logs when dependencies change- Universal reactivity: runes work in
.svelte.tsfiles, not just.svelte - Snippets:
{#snippet name()}{/snippet}replaces named slots;{@render snippet()}renders them - Svelte 4 syntax still works: backward-compatible, gradual migration supported
- Svelte 5 version: 5.45.0 (December 2025); GA October 22, 2024
At a Glance
| Concept | Svelte 4 | Svelte 5 (Runes) |
|---|---|---|
| Reactive state | let count = 0 | let count = $state(0) |
| Computed values | $: doubled = count * 2 | let doubled = $derived(count * 2) |
| Side effects | $: { doSomething(x) } | $effect(() => { doSomething(x) }) |
| Component props | export let name: string | let { name } = $props() |
| Two-way binding | export let value + bind:value | let { value = $bindable() } = $props() |
| Lifecycle | onMount, onDestroy | $effect with cleanup return |
| Named slots | <slot name="header"> | {#snippet header()}{/snippet} |
| Stores | writable(), $store | $state() in .svelte.ts files |
| Reactive in non-components | ❌ | ✅ (.svelte.ts files) |
$state: Reactive State
$state is the foundation. Declare any value as reactive state and Svelte tracks it:
<script>
let count = $state(0)
let name = $state('world')
let items = $state(['a', 'b', 'c'])
</script>
<button onclick={() => count++}>
Count: {count}
</button>
Deep Reactivity
$state is deeply reactive for objects and arrays — mutations are tracked automatically:
<script>
let user = $state({
name: 'Alice',
address: {
city: 'New York'
}
})
// All of these trigger updates
function updateCity() {
user.address.city = 'Boston' // deep mutation — tracked
}
let todos = $state([
{ id: 1, text: 'Buy milk', done: false },
])
function addTodo(text) {
todos.push({ id: Date.now(), text, done: false }) // push — tracked
}
function toggleTodo(id) {
const todo = todos.find(t => t.id === id)
if (todo) todo.done = !todo.done // mutation — tracked
}
</script>
$state.raw
For values where you want to opt out of deep reactivity (large arrays, expensive objects):
<script>
// $state.raw: whole assignment is reactive, but mutations are NOT tracked
let bigArray = $state.raw(new Array(10000).fill(0))
function update() {
bigArray = bigArray.map(x => x + 1) // ✅ replace whole array
bigArray[0] = 1 // ❌ mutation not tracked — use $state.raw when replacing, not mutating
}
</script>
Svelte 4 vs Svelte 5 State
<!-- Svelte 4 -->
<script>
let count = 0 // reactive inside .svelte, magic
let obj = { x: 1 } // obj.x mutation NOT reactive in Svelte 4
</script>
<!-- Svelte 5 -->
<script>
let count = $state(0) // explicit, works anywhere
let obj = $state({ x: 1 }) // obj.x mutation IS reactive — deep by default
</script>
$derived: Computed Values
$derived creates values that automatically recompute when their dependencies change:
<script>
let width = $state(10)
let height = $state(10)
let area = $derived(width * height)
let perimeter = $derived(2 * (width + height))
let isSquare = $derived(width === height)
</script>
<p>Area: {area}, Perimeter: {perimeter}</p>
{#if isSquare}<p>It's a square!</p>{/if}
$derived is lazy and always consistent — it recomputes only when accessed after a dependency changed, never runs unnecessarily.
$derived.by
For complex computations that need a function body (multiple statements):
<script>
let items = $state([3, 1, 4, 1, 5, 9, 2, 6])
// $derived works for simple expressions
let count = $derived(items.length)
// $derived.by for multi-statement logic
let sorted = $derived.by(() => {
// Run any code here — it's still reactive
const copy = [...items]
copy.sort((a, b) => a - b)
return copy
})
let stats = $derived.by(() => {
const sum = items.reduce((acc, n) => acc + n, 0)
const avg = sum / items.length
const max = Math.max(...items)
const min = Math.min(...items)
return { sum, avg, max, min }
})
</script>
Svelte 4 vs Svelte 5 Derived
<!-- Svelte 4 -->
<script>
let count = 0
$: doubled = count * 2 // reactive declaration — order-sensitive
$: {
console.log('count changed to', count) // also uses $:
// count, doubled, side effects — all mixed in $: blocks
}
</script>
<!-- Svelte 5 -->
<script>
let count = $state(0)
let doubled = $derived(count * 2) // clearly derived, not a side effect
$effect(() => {
console.log('count changed to', count) // clearly a side effect
})
</script>
The key Svelte 4 problem: $: could be either a computed value OR a side effect — same syntax for different things, execution order was implicit. Svelte 5 separates these cleanly.
$effect: Side Effects
$effect runs after the DOM has been updated, whenever reactive dependencies change:
<script>
let count = $state(0)
let searchQuery = $state('')
// Runs after every render where count changed
$effect(() => {
document.title = `Count: ${count}`
})
// Cleanup: return a function to run on teardown
$effect(() => {
const handler = (e) => {
searchQuery = e.key
}
document.addEventListener('keydown', handler)
return () => {
document.removeEventListener('keydown', handler) // cleanup
}
})
</script>
$effect.pre
Runs BEFORE DOM updates (like beforeUpdate in Svelte 4):
<script>
let messages = $state([])
let container
// $effect.pre: runs before DOM update
$effect.pre(() => {
// Snapshot scroll position before new messages added
const scrollBottom = container.scrollHeight - container.scrollTop
return () => {
// After update: restore scroll position
if (scrollBottom < 10) {
container.scrollTop = container.scrollHeight
}
}
})
</script>
$effect.tracking
Check whether you're inside a reactive context:
<script>
$effect(() => {
console.log('tracking:', $effect.tracking()) // true — inside effect
})
console.log('tracking:', $effect.tracking()) // false — outside effect
</script>
Svelte 4 vs Svelte 5 Effects
<!-- Svelte 4 -->
<script>
import { onMount, onDestroy } from 'svelte'
let intervalId
onMount(() => {
intervalId = setInterval(() => count++, 1000)
})
onDestroy(() => {
clearInterval(intervalId)
})
</script>
<!-- Svelte 5 — cleanup in same function -->
<script>
let count = $state(0)
$effect(() => {
const id = setInterval(() => count++, 1000)
return () => clearInterval(id) // cleanup co-located with setup
})
</script>
$props: Component Props
$props() replaces export let for declaring component props:
<!-- Svelte 4 -->
<script>
export let name: string
export let age: number = 25 // default value
export let onClick: () => void
</script>
<!-- Svelte 5 -->
<script lang="ts">
let { name, age = 25, onClick }: {
name: string
age?: number
onClick: () => void
} = $props()
</script>
Rest Props
<script lang="ts">
let { class: className, ...rest } = $props()
// className = the `class` prop
// rest = all other props (forwarded to underlying element)
</script>
<button class={className} {...rest}>
<slot />
</button>
TypeScript with $props
<script lang="ts">
interface Props {
title: string
count?: number
variant?: 'primary' | 'secondary'
onClose?: () => void
}
let { title, count = 0, variant = 'primary', onClose }: Props = $props()
</script>
$bindable: Two-Way Binding
When a parent needs to bind to a child's prop value:
<!-- Child.svelte -->
<script lang="ts">
let { value = $bindable('') }: { value?: string } = $props()
</script>
<input bind:value={value} />
<!-- Parent.svelte -->
<script>
let text = $state('')
</script>
<Child bind:value={text} />
<p>Current: {text}</p>
$bindable() declares that this prop can be two-way bound. Without it, bind:value on the parent would be a Svelte error.
$inspect: Development Logging
$inspect is a dev-only rune that logs reactive values whenever they change — like console.log but reactive:
<script>
let count = $state(0)
let user = $state({ name: 'Alice' })
// Logs to console whenever count or user changes (dev only — stripped in production)
$inspect(count, user)
// Custom log function
$inspect(count).with(console.trace)
$inspect(count).with((type, value) => {
if (type === 'update') debugger
})
</script>
$inspect is automatically removed in production builds. It's the reactive equivalent of adding $: console.log(x) in Svelte 4, but cleaner.
Universal Reactivity: Runes Outside .svelte
The most powerful change in Svelte 5: runes work in .svelte.ts and .svelte.js files, enabling reactive state outside of components:
// counter.svelte.ts
export function createCounter(initial = 0) {
let count = $state(initial)
let doubled = $derived(count * 2)
return {
get count() { return count },
get doubled() { return doubled },
increment() { count++ },
decrement() { count-- },
reset() { count = initial },
}
}
<!-- App.svelte -->
<script>
import { createCounter } from './counter.svelte.ts'
const counter = createCounter(10)
</script>
<button onclick={counter.increment}>+</button>
<p>{counter.count} (×2 = {counter.doubled})</p>
This replaces Svelte stores for most use cases. Stores (writable, readable, derived) still work in Svelte 5 but the .svelte.ts pattern is more idiomatic.
// Old pattern: Svelte store
import { writable, derived } from 'svelte/store'
export const count = writable(0)
export const doubled = derived(count, $c => $c * 2)
// New pattern: $state in .svelte.ts
// (see createCounter above — no store boilerplate)
Snippets: Replacing Slots
Svelte 5 replaces named slots with snippets:
<!-- Svelte 4: named slots -->
<Card>
<svelte:fragment slot="header">
<h1>Title</h1>
</svelte:fragment>
<p>Content here</p>
</Card>
<!-- Svelte 5: snippets -->
<Card>
{#snippet header()}
<h1>Title</h1>
{/snippet}
<p>Content here</p>
</Card>
Defining snippet-accepting props in a component:
<!-- Card.svelte -->
<script lang="ts">
import type { Snippet } from 'svelte'
let {
header,
children,
}: {
header?: Snippet
children: Snippet
} = $props()
</script>
<div class="card">
{#if header}
<div class="card-header">{@render header()}</div>
{/if}
<div class="card-body">{@render children()}</div>
</div>
Snippets with parameters:
<!-- Parent -->
<List items={todos}>
{#snippet item(todo)}
<span class:done={todo.done}>{todo.text}</span>
{/snippet}
</List>
<!-- List.svelte -->
<script lang="ts">
import type { Snippet } from 'svelte'
let { items, item }: { items: any[], item: Snippet<[any]> } = $props()
</script>
{#each items as i}
{@render item(i)}
{/each}
Migrating from Svelte 4
Svelte 5 is backward-compatible — Svelte 4 syntax still works. You can migrate incrementally:
Migration Tool
npx sv migrate svelte-5
This automatically converts most Svelte 4 patterns to runes. Review the output — some patterns need manual adjustment.
Common Conversions
<!-- Before (Svelte 4) -->
<script>
export let value = ''
export let disabled = false
let count = 0
$: doubled = count * 2
$: if (count > 10) {
alert('too high!')
}
import { onMount, onDestroy } from 'svelte'
let timer
onMount(() => { timer = setInterval(() => count++, 1000) })
onDestroy(() => clearInterval(timer))
</script>
<!-- After (Svelte 5 runes) -->
<script lang="ts">
let { value = '', disabled = false } = $props()
let count = $state(0)
let doubled = $derived(count * 2)
$effect(() => {
if (count > 10) alert('too high!')
})
$effect(() => {
const timer = setInterval(() => count++, 1000)
return () => clearInterval(timer)
})
</script>
What Breaks / Major Changes
<slot>withnameattribute — still works but snippets are preferred$: labelstatement — works but should migrate to$derived/$effect- Svelte stores with
$storesyntax — still work, no change needed yet export let— still works but should migrate to$props()createEventDispatcheris removed — use callback props instead:<!-- Svelte 4 --> <script> import { createEventDispatcher } from 'svelte' const dispatch = createEventDispatcher() dispatch('close', { reason: 'user' }) </script> <!-- Svelte 5 --> <script> let { onclose } = $props() onclose({ reason: 'user' }) // just a callback prop </script>$$props,$$restProps,$$slots— replaced by$props()rest spread (...rest) and{@render children()}checks<svelte:component this={...}>— deprecated; use the component directly as a value- Object/class mutation: class field reactivity requires
$stateat the field level, not wrappingnew Foo()with$state()
Performance and Bundle Size
Svelte 5 is meaningfully faster and produces smaller bundles than Svelte 4:
Bundle size reduction:
- Existing Svelte 4 apps see ~50% bundle size reduction after upgrading to Svelte 5 — same components, no code changes
- Real-world example: one app went from 261 KB → 181 KB (80 KB reduction)
- Svelte 5 runtime: ~1.6 KB gzipped (vs React 18 at ~42 KB, Vue 3 at ~22 KB)
Runtime benchmarks:
Framework runtime sizes (gzipped):
Svelte 5: ~1.6 KB
SolidJS: ~7 KB
Vue 3: ~22 KB
React 18: ~42 KB
Real-world e-commerce benchmark:
Svelte 5.38: 8.2 KB initial bundle, 156ms time-to-interactive
React 18: 47 KB initial bundle, 340ms time-to-interactive
js-framework-benchmark vs Svelte 4:
Simple app init: ~12% faster
Complex updates: ~15% faster
Overall ranking: 2nd (behind SolidJS, ahead of Vue 3 and React)
Why Svelte 5 bundles are smaller than Svelte 4 despite adding a signals runtime: fine-grained reactivity means fewer components re-render on any given update, so the compiler emits less update code per component. The compiled output is more compact. Svelte 5 is also more tree-shakeable.
The $state deep reactivity (proxy) does add some overhead for large object trees — use $state.raw when you have large arrays or objects where you're always replacing, not mutating.
Svelte 5 + SvelteKit
SvelteKit 2 supports Svelte 5 fully. Your load functions, form actions, and routes work identically. The main migration concern is component files — page and layout components need the same Svelte 4→5 conversion.
// +page.ts — unchanged
import type { PageLoad } from './$types'
export const load: PageLoad = async ({ params }) => {
return { id: params.id }
}
<!-- +page.svelte — migrate to runes -->
<script lang="ts">
import type { PageData } from './$types'
let { data }: { data: PageData } = $props()
// ^ $props() instead of export let data
</script>
<h1>Item {data.id}</h1>
$app/state replaces $app/stores
SvelteKit 2.12 added $app/state as the runes-based replacement for $app/stores:
// Svelte 4 / SvelteKit 1 — store syntax
import { page } from '$app/stores'
$page.url.pathname // $ prefix to auto-subscribe
// Svelte 5 / SvelteKit 2 — runes syntax
import { page } from '$app/state'
page.url.pathname // no $ prefix needed — it's a reactive object
$app/stores is deprecated as of SvelteKit 2.12. The page, navigating, and updated state objects from $app/state work in .svelte.ts files as well as components.
Compare Svelte and SvelteKit download trends on PkgPulse.
Related: SolidJS vs Svelte 5 vs React Reactivity · Vue 3 vs Svelte 5 2026 · React 19 vs Svelte 5 Compiler
See the live comparison
View svelte 5 runes on PkgPulse →