<!-- PkgPulse AI-readable guide source -->
<!-- Canonical: https://www.pkgpulse.com/guides/svelte-5-runes-complete-guide-2026 -->
<!-- Raw Markdown: https://www.pkgpulse.com/guides/svelte-5-runes-complete-guide-2026/raw.md -->
<!-- Source path: content/guides/svelte-5-runes-complete-guide-2026.mdx -->

---
og_image: "/images/guides/svelte-5-runes-complete-guide-2026.webp"
title: "Svelte 5 Runes: A Practical Guide 2026"
description: "Complete guide to Svelte 5 runes: $state, $derived, $effect, $props, $bindable. Reactivity system, migration from Svelte 4, and production patterns in 2026."
date: "2026-03-16"
tier: 1
authors: ["team"]
tags: ["svelte", "svelte-5", "runes", "reactivity", "javascript", "frontend"]
---

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

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

```svelte
<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:

```svelte
<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):

```svelte
<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
<!-- 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:

```svelte
<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):

```svelte
<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
<!-- 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:

```svelte
<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):

```svelte
<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:

```svelte
<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
<!-- 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
<!-- 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

```svelte
<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

```svelte
<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:

```svelte
<!-- 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:

```svelte
<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:

```typescript
// 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 },
  }
}
```

```svelte
<!-- 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.

```typescript
// 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
<!-- 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:

```svelte
<!-- 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:

```svelte
<!-- 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

```bash
npx sv migrate svelte-5
```

This automatically converts most Svelte 4 patterns to runes. Review the output — some patterns need manual adjustment.

### Common Conversions

```svelte
<!-- 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
  <!-- 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.

```typescript
// +page.ts — unchanged
import type { PageLoad } from './$types'

export const load: PageLoad = async ({ params }) => {
  return { id: params.id }
}
```

```svelte
<!-- +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`:

```typescript
// 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.

---

## Adopting Runes on an Existing Team

The Svelte 5 migration path is designed to be gradual, but the change in mental model is real. Svelte 4's implicit reactivity — where any `let` variable in a `.svelte` file was automatically reactive — felt like magic and caused subtle bugs when developers expected reactivity to work outside components or in plain `.ts` files. Runes are explicit and transportable: the same `$state` and `$derived` primitives work identically in component files and in standalone `.svelte.ts` modules, which eliminates the "why isn't this reactive?" confusion that was common in Svelte 4 codebases.

For teams migrating incrementally, the key insight is that Svelte 4 and Svelte 5 syntax coexist in the same project. Individual components can be migrated file by file without breaking others. The `npx sv migrate svelte-5` tool handles the mechanical conversion of most patterns. The patterns that still require human review are components using `$$props`, `$$restProps`, or `createEventDispatcher` — all of which have clean Svelte 5 equivalents but need manual verification that the new callback prop pattern matches how the component is used by its parents.

One area where developers get tripped up: class-based state. In Svelte 5, wrapping a class instance with `$state(new MyClass())` makes the reference reactive but does NOT make the class's internal properties reactive. For class-based state patterns, each reactive property must be declared with `$state` at the field level inside the class. This is a meaningful architectural change for codebases that use class instances extensively, and it is not caught by the auto-migration tool.

The Svelte team's commitment to backward compatibility in Svelte 5 means Svelte 4 component syntax continues to work — there is no forced migration deadline. This makes incremental adoption practical for large codebases: you can introduce runes in new components and utility files without touching existing Svelte 4 components, letting the migration happen naturally over multiple development cycles rather than as a single high-risk upgrade.

*Compare Svelte and SvelteKit download trends on [PkgPulse](https://www.pkgpulse.com/compare/svelte).*

*Related: [SolidJS vs Svelte 5 vs React Reactivity](/guides/solidjs-vs-svelte-5-vs-react-reactivity-2026) · [Vue 3 vs Svelte 5 2026](/guides/vue-3-vs-svelte-5-2026) · [React 19 vs Svelte 5 Compiler](/guides/react-19-compiler-vs-svelte-5-compiler-2026)*
