Svelte 5 Runes: The Reactivity Rewrite You Actually Need to Learn

Svelte 5 replaces the old let plus $: reactivity model with runes, a small set of compiler-recognized functions that look like normal JavaScript but get rewritten into fine-grained reactive code. Instead of declaring let count = 0 and hoping the compiler infers a reactive binding, you write let count = $state(0). Instead of $: doubled = count * 2, you write let doubled = $derived(count * 2). Instead of $: console.log(count), you reach for $effect(() => console.log(count)). Props become let { name } = $props().

The migration is a bigger break than the jump from Svelte 3 to 4, but the payoff is reactivity that is explicit, TypeScript-friendly, and works in plain .svelte.ts modules rather than being trapped inside component files. Svelte 5 has been stable since October 2024, the current release line sits around 5.55 as of April 2026, and SvelteKit 2 ships first-class rune support. The canonical references are the runes documentation and the original runes announcement post .

Why Svelte 5 Replaced $: and let-Based Reactivity

Before the new syntax makes sense, you need the design pressure that forced the change. The old model was elegant in demos but accumulated papercuts in real codebases, and runes are specifically built to fix them.

The let count = 0 magic only worked inside .svelte files. Any logic you tried to extract into a plain .ts helper lost reactivity, which pushed teams into ad-hoc store boilerplate. The Svelte team calls this out directly in the announcement post: the compiler heuristic only fires for top-level let declarations, and refactoring code out of a component silently breaks the reactive contract.

The $: label had its own problems. A line starting with $: might be a derived value, a side effect, or a statement that should re-run on dependency change, and the compiler had to guess. The answer often surprised TypeScript users, especially when a derivation was mistakenly treated as a statement or vice versa. Top-level $: also had no scoping rules: you could not wrap it in an if, you could not return early from it, and ordering bugs between multiple $: blocks in the same component were a common foot-gun.

Stores (writable, readable, derived) were a second reactivity system you had to learn alongside the component-level one. The $store auto-subscription syntax did not compose cleanly with TypeScript generics, and new contributors had to memorize which rules applied where. Runes fold all of that into one mental model: a small set of compiler-recognized functions, usable inside and outside components, with predictable dependency tracking based on signals rather than label parsing.

Svelte runes announcement social card featuring the Svelte wordmark and the word runes in bold orange type
The Svelte team's original runes announcement card from svelte.dev/blog/runes
Image: Svelte

The Five Core Runes You Will Use Every Day

Runes have a small surface area, but each one has a specific job and a few sharp edges.

  • $state(initial) declares a reactive value. let count = $state(0) gives you a deeply reactive binding you can mutate directly with count++ or user.name = 'Alice'. It replaces top-level let reactivity.
  • $derived(expression) declares a computed value. let doubled = $derived(count * 2). Dependencies are tracked automatically by which $state reads run during evaluation. It replaces $: doubled = count * 2.
  • $effect(() => { ... }) runs a side effect when its dependencies change, with the return value used as the cleanup function. It replaces side-effecting $: blocks and onMount patterns that wired up reactive subscriptions.
  • $props() destructures component props with full TypeScript inference. let { name, age = 18 }: { name: string; age?: number } = $props(). It replaces export let name.
  • $bindable() marks a prop as two-way bindable from the parent. let { value = $bindable() } = $props() lets a parent write <Child bind:value={x} />. It replaces the implicit bindability of export let.

Two compiler rules matter here. Runes can only appear at the top level of a component or inside a .svelte.js or .svelte.ts module, because the compiler needs to see the call site at build time. You also cannot import them, alias them, or call them dynamically. They look like functions, but they are keywords dressed up as functions so the syntax highlighters and LSPs treat them correctly.

Migrating a Svelte 4 Component to Runes

Concrete code is the fastest way to see the shift. Here is a canonical Svelte 4 counter:

<script>
  export let start = 0;
  let count = start;
  $: doubled = count * 2;
  $: if (count > 10) console.log('big');
  function inc() { count += 1; }
</script>

<button on:click={inc}>{count} ({doubled})</button>

The same component in Svelte 5 with runes looks like this:

<script lang="ts">
  let { start = 0 }: { start?: number } = $props();
  let count = $state(start);
  let doubled = $derived(count * 2);
  $effect(() => {
    if (count > 10) console.log('big');
  });
  function inc() { count += 1; }
</script>

<button onclick={inc}>{count} ({doubled})</button>

A few things happened at once. The on:click directive became a plain DOM onclick attribute, which is a Svelte 5 change that ships alongside runes but is technically separate. The TypeScript story is far cleaner: $props() gives you one typed destructure instead of a stack of export let foo: string lines, and $state infers the value type from its initializer. The $effect block also wraps the side-effecting $: into something scoped and readable.

Run the official migration tool with npx sv migrate svelte-5, or npx svelte-migrate svelte-5 on older projects. It handles the mechanical rewrites and leaves comments where it needs human review. What the tool cannot auto-fix cleanly: stores you used as writable plus auto-subscribe should usually become $state in a .svelte.ts module, but the codemod leaves the store working and just flags it as legacy. Backwards compatibility is real: Svelte 5 runs Svelte 4 components in legacy mode inside the same project, so you can migrate file-by-file instead of shipping one big-bang PR.

Diagram mapping five common Svelte 4 reactivity idioms to their Svelte 5 rune equivalents

Runes Outside Components With .svelte.ts

This is the feature that makes runes worth the migration on its own. Reactive logic can live in plain modules, not trapped inside component files.

The rule is simple. Any module file ending in .svelte.js or .svelte.ts is processed by the Svelte compiler and may use runes. Plain .ts files cannot, because the compiler does not run on them. Here is a reusable counter extracted into lib/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++,
  };
}

The getters are load-bearing. Runes are not values, they are reactive bindings, and you have to expose them through getters so callers read the current value at read time instead of a frozen snapshot taken at return time. Destructuring the return object would break reactivity the same way destructuring any $state value does.

Classes work too. Svelte 5 supports $state as a class field, so you can write:

export class Counter {
  count = $state(0);
  get doubled() { return this.count * 2; }
  increment() { this.count++; }
}

A new Counter() instance behaves the way you would hope, with deep reactivity on count and derived values that track it automatically. This pattern replaces about 90 percent of what Svelte stores were used for: shared state, derived state, and reactive helpers, all without a parallel API. Stores still exist and still work - anything that needs to interop with RxJS, with a third-party subscription source, or with legacy code that uses the $store auto-subscribe syntax should keep using writable and readable. Testing is where this pays off: .svelte.ts modules are plain JavaScript at runtime, so you can unit-test reactive logic with Vitest without mounting a component, which was painful in Svelte 4.

Illustration of the Svelte compiler machine taking component source code and outputting packaged runtime code
The Svelte compiler is what turns .svelte and .svelte.ts files into fine-grained reactive JavaScript - runes are the signals it reads at build time
Image: Svelte

Deep Reactivity, Snapshots, and Proxy Caveats

$state looks like a normal variable, but under the hood it is a Proxy that tracks reads and writes. That has consequences when you hand the value to non-Svelte code.

Deep reactivity is the default for objects and arrays. let user = $state({ name: 'Ada', address: { city: 'London' } }) makes both top-level and nested mutations reactive, so user.address.city = 'Paris' triggers updates without any spread-and-replace dance. Arrays work the same way: let todos = $state([]) lets you call todos.push(...), todos.splice(...), or assign by index, and dependents update correctly.

The footgun is what happens when the Proxy leaves Svelte’s orbit. Passing a $state object directly to console.log, JSON.stringify, structuredClone, or a third-party library may log a Proxy wrapper, produce surprising output, or trip a “cannot clone” error. The fix is $state.snapshot(value), which returns a plain, non-reactive deep copy of the current state. Use it before sending state over fetch, before writing to localStorage, and before passing state into chart libraries or PDF generators that hate Proxies.

$state.raw(initial) is the opt-out. It gives you a non-deep reactive binding that only updates when you replace the whole object by reference, rather than when you mutate into it. That is the right choice for large immutable payloads where Proxy overhead would matter - think a 10,000-row table that you only ever replace wholesale, never patch in place. The equality semantics follow from this: $state.raw skips a re-run when you assign a value that is === to the existing one, while deep $state always re-runs derivations on assignment, because the Proxy cannot cheaply prove structural equality.

Svelte 4 idiomSvelte 5 rune equivalent
let count = 0 (reactive)let count = $state(0)
$: doubled = count * 2let doubled = $derived(count * 2)
$: console.log(count)$effect(() => console.log(count))
export let name: stringlet { name }: { name: string } = $props()
export let value with bind:let { value = $bindable() } = $props()
writable(0) in store.ts$state(0) in store.svelte.ts
on:click={inc}onclick={inc}
$page from $app/storespage from $app/state

SvelteKit 2 Integration Patterns

Runes are a Svelte feature, but most readers will meet them inside a SvelteKit 2 app, and the integration points deserve specific attention.

SvelteKit 2 shipped alongside Svelte 5 in 2024 and is the default in 2026. It ships first-class rune support and updates +page.svelte examples to use $props() for the data prop: let { data } = $props(). The data you receive from a +page.ts load function arrives as a plain serialized object. Wrap it in $state if you want to mutate it locally on the page, otherwise it stays static and renders as-is. Most SvelteKit 2 projects pair the load layer with a typed ORM—if you are choosing between your options, weigh the TypeScript type-safety trade-offs before you commit.

Reactive query patterns fall out naturally. Use $derived to compute view-model values from data and from URL search params, and use $effect to react when a user toggles a filter. Prefer SvelteKit’s invalidate() and goto() for full reloads rather than firing fetch from inside $effect. Form actions keep working the way they did: enhanced forms still use use:enhance, and the returned form prop integrates cleanly through let { data, form } = $props(). You do not need stores to manage form state anymore. Runes change how form state flows, not how it should be marked up, so keep marking up form inputs accessibly the same way you always have.

The biggest ergonomic win is the new $app/state module. It replaces the legacy $app/stores import and exposes page as a rune-friendly reactive object you can read directly. Instead of import { page } from '$app/stores' followed by {$page.data}, you write import { page } from '$app/state' and then {page.data}. The SvelteKit docs note that $app/stores is deprecated and will be removed in SvelteKit 3. Fine-grained reactivity is the bonus: individual properties on page now update independently rather than invalidating the whole store.

Server-only modules can use runes too. A lib/cart.svelte.ts file can be imported from both client and server code, and $effect simply does not run on the server, so isomorphic reactive logic works without ceremony. One upgrade gotcha: SvelteKit 2 dropped several Svelte 4 APIs, including parts of $app/stores. The sv migrate sveltekit-2 codemod is the companion to sv migrate svelte-5 and should be run at the same time, because mixing the two migrations across commits tends to produce half-working states that are hard to debug. If your SvelteKit project also uses Tailwind CSS, moving its config over to the v4 setup follows a similarly config-replacing upgrade pattern and can be run in the same PR.

When to Migrate

If you are starting a new project today, use Svelte 5 and runes from day one. The legacy syntax still works, but you will not want two reactivity models in a fresh codebase. Teams that need highly portable UI pieces alongside their SvelteKit app may also want to look at building framework-agnostic UI elements , which ship without a compiler and work in any context. If you maintain an existing Svelte 4 app, the migration is low-risk thanks to the file-by-file legacy mode. Run the codemod, fix the flagged spots, and convert stores to .svelte.ts modules as you touch the features that use them. The parts that pay back fastest are the stores you rewrite into reactive classes and the form components where $props plus $bindable flatten a stack of export let declarations into one line.

Runes are a pragmatic fix for the reactivity edges that Svelte 4 users tripped over every week, packaged in a syntax that survives refactoring into modules, plays nicely with TypeScript, and gives the compiler enough information to produce the fine-grained updates the project always wanted to ship.