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, from the core primitives to the production trade-offs that decide whether and when to adopt them.
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, and there’s no longer a class of bug where the order of statements changes whether reactivity fires.
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. Under the hood, the compiler turns each rune into a fine-grained signal, so the runtime knows exactly which DOM nodes depend on which value without diffing a virtual tree.
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({ id: crypto.randomUUID(), text, completed: false });
}
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, and the result is memoized so repeated reads are cheap.
// 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 ? (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;
});
Props, Bindings, and Two-Way Data Flow
Component inputs change under runes too. The familiar export let becomes destructuring from the $props rune, which makes default values, renaming, and rest props read like ordinary JavaScript. For two-way binding, the new $bindable rune marks a prop a parent is allowed to bind to — an explicit contract that replaces the implicit bindability of every Svelte 4 prop. This explicitness matters in shared component libraries, where you want to advertise exactly which props support bind: and which are one-directional.
// Child component: declare props and an opt-in bindable value
<script>
let {
label,
disabled = false,
value = $bindable(''), // parent may bind to this
...rest
} = $props();
</script>
<input {disabled} bind:value {...rest} aria-label={label} />
// Parent component: two-way binding works as before
<script>
let name = $state('');
</script>
<TextField label="Name" bind:value={name} />
<p>Hello, {name}</p>
Because props are now plain destructured values, you also get clean TypeScript: annotate the destructured object with an interface and the editor infers everything downstream. This removes one of the more awkward corners of Svelte 4, where typing props meant repeating export let declarations alongside a separate type.
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, and a mixed codebase compiles and ships fine in the meantime.
// 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(). Event handlers also lose their colon — on:click becomes a plain onclick attribute, and the old slot syntax gives way to snippets. Furthermore, the Svelte team provides an automated migration tool (npx sv migrate svelte-5) that handles most of these transformations, though you should still review effects by hand, since the tool cannot always tell a genuine side effect from a value that should be a $derived.
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 that several components import without resorting to stores. Note the one rule that trips people up: a function cannot simply return a reactive variable by value, because the binding would be frozen at call time. Instead you expose getters, so each read re-subscribes to the signal.
// 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 in most workloads. The new fine-grained signal system means only the specific DOM nodes that depend on changed state get updated — there is no component-level re-execution as state changes. Published benchmarks from the Svelte team show meaningfully faster updates for complex UIs with many independent reactive values, and the compiler emits less code per component because the runtime does less bookkeeping.
However, there are important considerations for production use. First, $effect should be used sparingly — prefer $derived for computed values and reserve effects for genuine side effects like API calls, DOM manipulation, or logging. Overusing effects recreates the same dependency-tracking headaches that plagued React’s useEffect, including the dreaded feedback loop where an effect writes state that retriggers itself. Second, deeply reactive $state objects use Proxies under the hood, which adds a small per-access cost. For very large arrays or data tables (tens of thousands of rows), reach for $state.raw(), which opts out of deep reactivity and reassigns the whole value instead of tracking each property.
Additionally, the Svelte 5 compiler typically produces smaller output, so bundle sizes tend to shrink after migration even before any other optimization. That said, the win is not automatic everywhere — heavily store-based Svelte 4 apps may see modest changes until the stores are rewritten as rune modules. As always, measure your own bundle and runtime profile rather than relying on a headline number.
When NOT to Reach for Runes: Trade-offs
Runes are powerful, but they are not the answer to every problem. A common anti-pattern is converting a perfectly good $derived into an $effect that manually assigns to a $state variable — this reintroduces ordering bugs and extra renders for no benefit. If a value can be expressed as a pure function of other state, it should be derived, not effected. Similarly, do not wrap one-time setup that has nothing to do with reactivity inside an effect; ordinary module-level code or lifecycle functions are clearer.
There are also adoption-timing trade-offs. If your project depends on a Svelte component library that has not shipped a Svelte 5 build, compatibility mode will carry you, but you may hit edge cases around slots-versus-snippets or event forwarding that require waiting for upstream updates. For a brand-new project, starting on Svelte 5 is the obvious call; for a large, store-heavy production app on a tight roadmap, a staged migration behind compatibility mode is usually wiser than a big-bang rewrite. For broader context on shipping fast front ends, see the web performance and Core Web Vitals guide.
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. Reach for $derived before $effect, lean on compatibility mode while your dependencies catch up, and start migrating your simplest components first as you work toward full adoption.