Svelte 5 Runes: A Complete Guide to the New Reactivity System
Svelte 5 introduces runes — a fundamental rethinking of how reactivity works in the framework. Instead of the compiler-magic $: reactive declarations from Svelte 4, runes use explicit function-like primitives: $state, $derived, and $effect. Therefore, Svelte 5 runes make reactivity predictable, composable, and easier to reason about in large codebases. This guide covers everything you need to migrate from Svelte 4 and master the new system.
Why Svelte Replaced Reactive Declarations with Runes
Svelte 4’s reactivity was elegant but had real limitations. The $: syntax only worked at the top level of components — you couldn’t extract reactive logic into separate files or share it between components without stores. Moreover, the compiler-based approach made it unclear when something was reactive versus a plain assignment. Developers frequently hit confusing bugs where reassigning a variable triggered reactivity but mutating an object didn’t.
Runes solve these problems by making reactivity explicit and portable. A $state value is reactive wherever you use it — in a component, a utility function, or a shared module. Additionally, the mental model is simpler: if you see $state, the value is reactive. If you don’t, it’s a plain JavaScript value. There’s no hidden compiler behavior to memorize.
The change also aligns Svelte with how other frameworks handle reactivity. React has hooks, Vue has the Composition API, and Solid has signals. Runes are Svelte’s answer — but with the compilation step that makes them more performant than runtime-only approaches.
Core Runes: $state, $derived, and $effect
$state declares reactive state. Unlike Svelte 4 where any top-level let was reactive, you now explicitly opt in. The value is deeply reactive — mutating nested objects and arrays triggers updates automatically.
// Svelte 4: implicit reactivity
let count = 0;
let items = [];
// Svelte 5: explicit $state rune
let count = $state(0);
let items = $state([]);
// Deep reactivity works automatically
items.push({ name: 'New item' }); // Triggers re-render
items[0].name = 'Updated'; // Also triggers re-render
// Class-based state
class TodoStore {
todos = $state([]);
filter = $state('all');
get filtered() {
if (this.filter === 'all') return this.todos;
return this.todos.filter(t =>
this.filter === 'done' ? t.completed : !t.completed
);
}
add(text) {
this.todos.push({ text, completed: false, id: crypto.randomUUID() });
}
toggle(id) {
const todo = this.todos.find(t => t.id === id);
if (todo) todo.completed = !todo.completed;
}
}
export const store = new TodoStore();$derived replaces $: for computed values. It takes an expression and re-evaluates whenever its dependencies change. Unlike $:, derived values are lazy — they only recompute when actually read.
// Svelte 4
$: doubled = count * 2;
$: total = items.reduce((sum, item) => sum + item.price, 0);
// Svelte 5
let doubled = $derived(count * 2);
let total = $derived(items.reduce((sum, item) => sum + item.price, 0));
// Complex derivations with $derived.by()
let stats = $derived.by(() => {
const completed = todos.filter(t => t.completed).length;
const remaining = todos.length - completed;
const percentage = todos.length ? Math.round((completed / todos.length) * 100) : 0;
return { completed, remaining, percentage };
});$effect runs side effects when its dependencies change. It replaces $: statements that performed actions rather than computing values. Effects run after the DOM updates and automatically clean up when the component unmounts.
// Svelte 4: side effect with reactive declaration
$: document.title = `(${unreadCount}) Messages`;
$: if (query.length > 2) fetchResults(query);
// Svelte 5: explicit effects
$effect(() => {
document.title = `(${unreadCount}) Messages`;
});
$effect(() => {
if (query.length > 2) {
fetchResults(query);
}
});
// Cleanup with return value
$effect(() => {
const interval = setInterval(() => {
elapsed += 1;
}, 1000);
return () => clearInterval(interval); // Cleanup on destroy
});
// $effect.pre runs before DOM updates (like beforeUpdate)
$effect.pre(() => {
shouldAutoScroll = div && div.offsetHeight + div.scrollTop >
div.scrollHeight - 20;
});Migrating from Svelte 4: Practical Patterns
Migration doesn’t have to happen all at once. Svelte 5 includes a compatibility mode that runs Svelte 4 components unchanged. You can migrate file by file, starting with the simplest components.
// BEFORE: Svelte 4 component
<script>
export let name;
export let greeting = 'Hello';
let count = 0;
$: message = `${greeting}, ${name}! Count: ${count}`;
$: if (count > 10) console.log('High count!');
function increment() { count += 1; }
</script>
// AFTER: Svelte 5 with runes
<script>
let { name, greeting = 'Hello' } = $props();
let count = $state(0);
let message = $derived(`${greeting}, ${name}! Count: ${count}`);
$effect(() => {
if (count > 10) console.log('High count!');
});
function increment() { count += 1; }
</script>The key migration patterns are: export let becomes $props() with destructuring, let at top level becomes $state(), computed $: becomes $derived(), and side-effect $: becomes $effect(). Furthermore, the Svelte team provides an automated migration tool (npx sv migrate svelte-5) that handles most of these transformations automatically.
Reusable Reactive Logic: Runes Outside Components
The biggest advantage of Svelte 5 runes over Svelte 4’s reactivity is portability. You can use runes in plain .svelte.js or .svelte.ts files — no component required. This enables custom hooks, shared state modules, and reactive utility functions.
// lib/useMousePosition.svelte.js — reusable reactive logic
export function useMousePosition() {
let x = $state(0);
let y = $state(0);
$effect(() => {
function handler(e) {
x = e.clientX;
y = e.clientY;
}
window.addEventListener('mousemove', handler);
return () => window.removeEventListener('mousemove', handler);
});
return {
get x() { return x; },
get y() { return y; }
};
}
// lib/useLocalStorage.svelte.js — persistent reactive state
export function useLocalStorage(key, initialValue) {
let value = $state(
JSON.parse(localStorage.getItem(key) ?? JSON.stringify(initialValue))
);
$effect(() => {
localStorage.setItem(key, JSON.stringify(value));
});
return {
get value() { return value; },
set value(v) { value = v; }
};
}
// Usage in a component
<script>
import { useMousePosition } from '$lib/useMousePosition.svelte.js';
import { useLocalStorage } from '$lib/useLocalStorage.svelte.js';
const mouse = useMousePosition();
const theme = useLocalStorage('theme', 'dark');
</script>
<p>Mouse: {mouse.x}, {mouse.y}</p>
<button onclick={() => theme.value = theme.value === 'dark' ? 'light' : 'dark'}>
Toggle theme ({theme.value})
</button>Performance Benefits and Production Considerations
Svelte 5’s rune-based reactivity is measurably faster than Svelte 4. The new fine-grained signal system means only the specific DOM nodes that depend on changed state get updated — no component-level diffing. Benchmarks show 20-40% faster updates for complex UIs with many reactive values.
However, there are important considerations for production use. First, $effect should be used sparingly — prefer $derived for computed values and only use effects for genuine side effects like API calls, DOM manipulation, or logging. Overusing effects creates the same dependency-tracking headaches that plagued React’s useEffect. Second, deeply reactive $state objects use Proxies under the hood. For very large arrays (10,000+ items), consider using $state.raw() which opts out of deep reactivity for better performance.
Additionally, the Svelte 5 compiler produces smaller output. Components compile to fewer JavaScript instructions because the reactivity system is more efficient. Consequently, bundle sizes typically decrease by 10-15% after migration, even without any other optimizations.
Related Reading:
- Astro Framework for Content-Driven Websites
- HTMX Modern Web Development
- Web Performance and Core Web Vitals
Resources:
In conclusion, Svelte 5 runes transform reactivity from compiler magic into explicit, portable primitives. The migration path is incremental, the performance gains are real, and the ability to share reactive logic across files eliminates the biggest limitation of Svelte 4. Start migrating your simplest components first and work toward full adoption.