Svelte 5 Runes — Reactivity Reimagined
Three months after Svelte 5's release, I've migrated three projects and taught one semester using the new runes API. The verdict: runes are the most significant improvement to Svelte since the compiler itself. Here's what changed, why it matters, and what the migration actually looks like.
What Are Runes?
Runes are Svelte 5's new reactivity primitives — function-like symbols prefixed with $ that replace Svelte's previous compiler-magic reactivity. The core runes:
$state— declares reactive state$derived— computes values from state (replaces$:reactive declarations)$effect— runs side effects when dependencies change (replaces$:reactive statements)$props— declares component props (replacesexport let)$bindable— marks a prop as two-way bindable
<script lang="ts">
let count = $state(0);
let doubled = $derived(count * 2);
$effect(() => {
console.log(`count changed to ${count}`);
});
</script>
<button onclick={() => count++}>
{count} × 2 = {doubled}
</button>
Why the Change?
Svelte's old reactivity was elegant but had real limitations that became pain points in larger applications:
The let Problem
In Svelte 4, reactivity was tied to let declarations at the top level of a component. This meant:
<!-- Svelte 4 -->
<script>
let count = 0; // reactive ✓
$: doubled = count * 2; // reactive ✓
function createCounter() {
let inner = 0; // NOT reactive ✗
return { inner };
}
</script>
Reactivity didn't work inside functions, classes, or modules. You couldn't extract reactive logic into a shared utility without reaching for stores — a separate API with different semantics.
Runes Fix This
With $state, reactivity is explicit and works everywhere:
// lib/counter.svelte.ts
export function createCounter(initial = 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
};
}
<!-- +page.svelte -->
<script lang="ts">
import { createCounter } from '$lib/counter.svelte';
const counter = createCounter(10);
</script>
<button onclick={counter.increment}>{counter.count}</button>
<p>Doubled: {counter.doubled}</p>
Reactive logic extracted, shared, tested — no stores, no context, no boilerplate. The .svelte.ts extension tells the compiler to process runes in plain TypeScript files.
$effect vs. $derived — Know the Difference
The most common migration mistake I've seen (and made) is reaching for $effect when $derived is the right tool:
<script>
let firstName = $state('');
let lastName = $state('');
// ✗ Wrong — using effect to compute a value
let fullName = $state('');
$effect(() => {
fullName = `${firstName} ${lastName}`;
});
// ✓ Right — derived computation
let fullName = $derived(`${firstName} ${lastName}`);
</script>
$derived is synchronous and pure — the compiler can optimize it. $effect is for side effects: DOM manipulation, logging, network requests. If you're computing a value from other values, use $derived.
Props and Snippets
Component APIs got cleaner too. Props use destructuring with $props:
<script lang="ts">
interface Props {
title: string;
count?: number;
children: import('svelte').Snippet;
}
let { title, count = 0, children } = $props<Props>();
</script>
<div>
<h2>{title} ({count})</h2>
{@render children()}
</div>
Snippets replace slots with a more flexible, composable pattern. They're just functions that render markup — you can pass them as props, store them in variables, or call them conditionally.
Migration in Practice
For my course management tool (~40 components), the migration took a weekend:
- Update
svelte.config.js— enable Svelte 5 compatibility mode - Run
npx sv migrate svelte-5— automated migration handles ~80% of changes - Fix remaining issues — mostly
$:statements that needed manual conversion to$derivedor$effect - Update component props —
export let→$propsdestructuring
The automated migration tool is impressively good. Most components needed only minor touch-ups.
Performance Gains
Svelte 5's new reactivity engine is faster across the board. In my benchmarks:
- Initial render — 15-20% faster due to optimized component instantiation
- State updates — significantly faster for components with many reactive bindings
- Memory usage — reduced by ~30% thanks to the new signal-based engine
The gains come from Svelte 5's signal-based reactivity under the hood. Unlike Svelte 4's top-down dirty checking, signals track dependencies at a granular level and only update what actually changed.
Teaching with Runes
For my web development course, runes are a pedagogical improvement. The old $: syntax was magical — students had to learn that $: doubled = count * 2 was fundamentally different from let doubled = count * 2, even though they looked similar.
With runes, the intent is explicit:
let count = $state(0); // "this is state"
let doubled = $derived(count * 2); // "this is computed from state"
$effect(() => { ... }); // "this runs when state changes"
Each concept has its own primitive. No magic, no ambiguity.
Final Thoughts
Runes trade a small amount of Svelte's famous brevity for significantly better composability, type safety, and scalability. The old syntax was beautiful for demos and small components, but runes are what Svelte needed to be taken seriously for large applications.
If you're still on Svelte 4, the migration is smoother than you'd expect. Start with the automated tool and spend an afternoon on the rest. The improved developer experience and performance are worth it.