Skip to main content

Svelte 5 Runes: Complete Guide 2026

·PkgPulse Team

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 — replaces let count = 0 in Svelte 4
  • $derived(expr): computed values — replaces $: doubled = count * 2
  • $effect(() => {}): side effects with auto-cleanup — replaces $: { sideEffect() }
  • $props(): component props — replaces export 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.ts files, 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

ConceptSvelte 4Svelte 5 (Runes)
Reactive statelet count = 0let count = $state(0)
Computed values$: doubled = count * 2let doubled = $derived(count * 2)
Side effects$: { doSomething(x) }$effect(() => { doSomething(x) })
Component propsexport let name: stringlet { name } = $props()
Two-way bindingexport let value + bind:valuelet { value = $bindable() } = $props()
LifecycleonMount, onDestroy$effect with cleanup return
Named slots<slot name="header">{#snippet header()}{/snippet}
Storeswritable(), $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> with name attribute — still works but snippets are preferred
  • $: label statement — works but should migrate to $derived / $effect
  • Svelte stores with $store syntax — still work, no change needed yet
  • export let — still works but should migrate to $props()
  • createEventDispatcher is 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 $state at the field level, not wrapping new 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

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.