Svelte 5 Runes Explained: Migrating from $: Reactive Statements to $state, $derived, and $effect
Svelte 5 replaces implicit $: reactive statements with explicit runes. Learn when to use $state, $derived, and $effect, with a worked migration and the mistakes that cause loops.
12 Oct 2025, 10:41 UTC

The short answer
In Svelte 5, reactivity is declared explicitly with runes — compiler-recognized functions like $state, $derived, and $effect — instead of Svelte 4's implicit let declarations and $: labels. The practical rule for migration is simple: let x = ... that changes becomes $state(...), a $: statement that only computes a value becomes $derived(...), and a $: statement that performs a side effect (logging, DOM work, network calls) becomes $effect(...). Most of your old $: blocks should become $derived, not $effect — that distinction is where most migration bugs come from.
Why the change
Svelte 4's $: label worked only at the top level of a component and re-ran based on whatever the compiler saw referenced inside it. That was convenient but hard to reason about in large components, and it could not be reused in ordinary JavaScript modules. Runes make reactivity an explicit, fine-grained contract: the compiler instruments reads and writes of state, and updates target only the DOM nodes that depend on the changed value. There is no virtual DOM diffing pass. Runes also work in .svelte.js and .svelte.ts files, so reactive logic can live outside components.
A worked component, before and after
Here is a small Svelte 4 cart summary component:
<!-- Svelte 4 -->
<script>
export let items = [];
let discount = 0.1;
$: subtotal = items.reduce((sum, i) => sum + i.price, 0);
$: total = subtotal * (1 - discount);
$: console.log('total changed', total);
</script>
<p>Total: {total.toFixed(2)}</p>The Svelte 5 runes version:
<!-- Svelte 5, runes mode -->
<script>
let { items = [] } = $props();
let discount = $state(0.1);
const subtotal = $derived(items.reduce((sum, i) => sum + i.price, 0));
const total = $derived(subtotal * (1 - discount));
$effect(() => {
console.log('total changed', total);
});
</script>
<p>Total: {total.toFixed(2)}</p>Three things changed. Props come from $props() instead of export let. The two computed values use $derived, which memoizes the result and only recomputes when its dependencies change. The logging side effect moves into $effect, which runs after the DOM updates and re-runs whenever any state read inside it (here, total) changes. Event attributes also lose the colon: on:click becomes onclick.
How $state tracks mutations
$state wraps objects and arrays in a proxy, so both reassignment and in-place mutation are tracked:
<script>
let cart = $state({ items: [] });
function addItem(item) {
cart.items.push(item); // tracked — the UI updates
}
</script>The important caveat: destructuring breaks the link to the proxy. const { items } = cart; gives you a plain snapshot that will not stay reactive. Access cart.items directly, or derive what you need with $derived.
Limits and common mistakes
- Deriving state inside $effect. Writing
$effect(() => { total = subtotal * 2 })is the most common Svelte 5 mistake. Effects that write state they also read can loop or produce stale updates, and the compiler will warn. If a value can be computed from other state, use$derived. Reserve$effectfor things that leave the reactive system: logging, analytics, third-party DOM libraries, timers. - Mixing modes. Svelte 5 keeps the legacy
$:syntax working for backward compatibility, but a single component cannot mix runes and legacy reactivity. Migration is per-component: convert one component fully, verify it, move on. - Calling runes in plain .js files. Runes are compiler features. They work in
.sveltefiles and in.svelte.js/.svelte.tsmodules only. In a regular.jsfile they are undefined functions and will fail at runtime or build time. - Assuming $effect runs before paint. Effects run after the DOM has updated. If you need to measure or adjust the DOM synchronously, check the current docs for the appropriate before-update hook rather than forcing it into an effect.
Verifying the migration
First confirm you are actually on Svelte 5 and in runes mode:
# run in the project root; read-only
grep '"svelte"' package.jsonYou want a 5.x version. Then, in the running dev server (npm run dev), check three behaviors in the migrated component: pushing into a $state array updates the rendered list; a destructured copy of state does not update (confirming the proxy model); and the $effect log fires once per actual change, not repeatedly. If the log fires in a tight loop, something in the effect is writing state it reads — find it and convert that write to a $derived value. Exact compiler warnings and rune edge cases are version-sensitive, so treat the installed version's documentation and compiler output as the final word.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.