Svelte 5 & Runes
Svelte 5 introduced runes, a new reactivity primitive that replaces the implicit reactive declarations from Svelte 4. Runes are function-like compiler instructions prefixed with $ that make reactivity explicit, composable, and predictable. This is the largest change in Svelte's history, and it aligns Svelte with the signals-based reactivity model used by Solid, Angular, and others.
Why Runes Replaced Reactive Declarations
In Svelte 4, reactivity was implicit. Variables declared with let at the top level of a component were reactive. Reactive statements used the $: label syntax borrowed from JavaScript's label statement.
<!-- Svelte 4 syntax (legacy) -->
<script>
let count = 0;
$: doubled = count * 2;
$: console.log('count changed:', count);
</script>
This was elegant but had problems:
- Only worked at the component top level: You could not extract reactive logic into separate functions or modules. A utility function in
$lib/utils.tscould not use$:because the compiler only processed.sveltefiles. - Ambiguous execution order: Multiple
$:statements had no guaranteed execution order, leading to subtle bugs when they depended on each other. - Confusing for newcomers:
$:is valid JavaScript (a label), but Svelte repurposed it with magical semantics. New developers regularly misunderstood when and why it ran. - Poor TypeScript integration: The
$:syntax made type inference unreliable and IDE support inconsistent.
Runes solve all of these problems by making reactivity explicit and portable.
The Runes
$state
$state declares reactive state. It replaces the implicit let reactivity from Svelte 4.
<script>
let count = $state(0);
let user = $state({ name: 'Alice', age: 30 });
</script>
<button onclick={() => count++}>Count: {count}</button>
<button onclick={() => user.age++}>Age: {user.age}</button>
Objects and arrays passed to $state become deeply reactive. Mutating a nested property triggers updates automatically.
$derived
$derived creates a value computed from other reactive values. It replaces $: derived = ... from Svelte 4.
<script>
let count = $state(0);
let doubled = $derived(count * 2);
let message = $derived(count === 1 ? '1 item' : `${count} items`);
</script>
<p>{doubled}</p>
<p>{message}</p>
For multi-line computations, use $derived.by:
<script>
let items = $state([1, 2, 3, 4, 5]);
let stats = $derived.by(() => {
const sum = items.reduce((a, b) => a + b, 0);
const avg = sum / items.length;
return { sum, avg };
});
</script>
<p>Sum: {stats.sum}, Average: {stats.avg}</p>
$derived values are read-only. Attempting to assign to them is a compiler error.
$effect
$effect runs side effects when its dependencies change. It replaces $: { sideEffect() } from Svelte 4.
<script>
let query = $state('');
$effect(() => {
console.log('Search query changed:', query);
// Dependencies are tracked automatically
// This runs whenever `query` changes
});
</script>
<input bind:value={query} placeholder="Search...">
Effects run after the DOM updates. They can return a cleanup function:
<script>
let interval = $state(1000);
$effect(() => {
const id = setInterval(() => {
console.log('tick');
}, interval);
return () => clearInterval(id);
});
</script>
The cleanup function runs before the effect re-runs and when the component is destroyed.
$props
$props declares the props a component accepts. It replaces export let from Svelte 4.
<script>
let { title, count = 0, onchange } = $props();
</script>
<h2>{title}</h2>
<button onclick={() => onchange(count + 1)}>Count: {count}</button>
With TypeScript:
<script lang="ts">
interface Props {
title: string;
count?: number;
onchange: (value: number) => void;
}
let { title, count = 0, onchange }: Props = $props();
</script>
$bindable
$bindable marks a prop as supporting two-way binding with bind:.
<!-- ColorPicker.svelte -->
<script lang="ts">
let { value = $bindable('#000000') }: { value: string } = $props();
</script>
<input type="color" bind:value>
<!-- Parent.svelte -->
<script>
import ColorPicker from './ColorPicker.svelte';
let color = $state('#ff0000');
</script>
<ColorPicker bind:value={color} />
<p>Selected: {color}</p>
Without $bindable, attempting to use bind: on that prop from the parent is a compiler error.
How Runes Work Under the Hood
Runes are compiler instructions, not runtime functions. The $state, $derived, and $effect calls are analyzed and transformed at compile time. You cannot call them dynamically or pass them around as values.
Under the hood, Svelte 5 implements a signals-based reactivity system:
$statecreates a signal (a reactive container for a value). Reading the signal registers a dependency. Writing to it notifies subscribers.$derivedcreates a computed signal that lazily recalculates when its dependencies change.$effectcreates a reaction that re-runs when any signal it reads during execution changes.
This is the same core model used by Solid.js, Preact Signals, and Angular Signals. The key difference is that Svelte's compiler handles the boilerplate: you write let count = $state(0) and the compiler generates the signal setup, the getter/setter wiring, and the dependency tracking.
// Conceptual representation of what the compiler generates
// (not actual Svelte output, simplified for illustration)
import { signal, derived, effect } from 'svelte/internal';
const count = signal(0); // $state(0)
const doubled = derived(() => count.get() * 2); // $derived(count * 2)
effect(() => { // $effect(() => ...)
console.log(count.get());
});
Migration from Svelte 4
Svelte 5 includes a compatibility mode that supports Svelte 4 syntax, so migration can be incremental. The key changes:
| Svelte 4 | Svelte 5 |
|---|---|
let x = 0 (reactive) |
let x = $state(0) |
$: doubled = x * 2 |
let doubled = $derived(x * 2) |
$: { sideEffect() } |
$effect(() => { sideEffect() }) |
export let title |
let { title } = $props() |
export let value (bindable) |
let { value = $bindable() } = $props() |
createEventDispatcher() |
Callback props |
The Svelte team provides an automatic migration tool:
npx sv migrate svelte-5
This handles most mechanical transformations. You will still need to review the output, especially around reactive statements that had implicit dependencies or relied on execution order.
The Mental Model: Signals-Based Reactivity
Think of runes as a way to declare relationships between values:
$statesays "this value can change, and when it does, anything that depends on it should update."$derivedsays "this value is computed from other reactive values, and it stays in sync automatically."$effectsays "run this code whenever the reactive values it reads change."
The dependency graph is built automatically. You do not manually subscribe to changes or declare dependencies in an array (like React's useEffect dependency array). Svelte tracks which signals are read during execution and wires up the subscriptions.
<script>
let firstName = $state('Jane');
let lastName = $state('Doe');
let fullName = $derived(`${firstName} ${lastName}`);
$effect(() => {
document.title = fullName;
// Automatically depends on fullName, which depends on firstName and lastName
});
</script>
<input bind:value={firstName}>
<input bind:value={lastName}>
<p>{fullName}</p>
Runes Outside Components
One of the biggest advantages of runes over Svelte 4's reactivity is that runes work in .svelte.ts and .svelte.js files, not just .svelte components. This means you can extract reactive logic into reusable modules:
// src/lib/counter.svelte.ts
export function createCounter(initial: number = 0) {
let count = $state(initial);
let doubled = $derived(count * 2);
function increment() { count++; }
function reset() { count = initial; }
return {
get count() { return count; },
get doubled() { return doubled; },
increment,
reset
};
}
<script>
import { createCounter } from '$lib/counter.svelte';
const counter = createCounter(10);
</script>
<button onclick={counter.increment}>{counter.count} (doubled: {counter.doubled})</button>
<button onclick={counter.reset}>Reset</button>
Note the getter syntax in the return object. Returning { count } directly would capture the value at the time of the return, losing reactivity. Getters ensure the current value is read each time.
Common Pitfalls
- **Destructuring state({ name: 'Alice' })` gives you a plain string, not a reactive binding. Keep the object intact and access properties on it.
- Forgetting that $effect tracks dependencies automatically: Unlike React's
useEffect, you do not provide a dependency array. If your effect reads a reactive value, it will re-run when that value changes. If you want to read a value without tracking it, useuntrack(). - **Using derived` instead. Effects are for side effects (DOM manipulation, API calls, logging), not for computing values.
- Trying to use runes in plain .ts files: Runes only work in
.svelteand.svelte.ts/.svelte.jsfiles. The compiler must process the file for runes to be transformed. - Returning reactive state without getters: When returning
$statevalues from functions, use getter properties or the caller will get a snapshot, not a live reference.
Key Takeaways
- Runes (
$state,$derived,$effect,$props,$bindable) make Svelte's reactivity explicit and composable. - They replace Svelte 4's implicit
letreactivity and$:reactive declarations. - Under the hood, runes implement a signals-based reactivity system with automatic dependency tracking.
- Runes work in
.svelte.tsfiles, enabling reusable reactive logic outside components. - Migration from Svelte 4 can be incremental, with tooling to automate mechanical changes.
- The mental model is straightforward: declare state, derive values, run effects. Dependencies are tracked automatically.