Migrating Svelte 4 Reactive Statements to Runes: What Actually Changes
Svelte 5 runes replace the implicit $: magic with explicit $state, $derived, and $effect. Here's how to migrate a real component, the mistake most people make, and the trade-offs.
04 Feb 2026, 20:54 UTC

If you've written Svelte 4, you know the pattern: declare a variable with let, then sprinkle $: labels around to recompute things when it changes. It works, but the reactivity is invisible — a label that looks like a JavaScript label but isn't, compiler magic that stops working the moment you move logic into a plain .js file. Svelte 5's runes ($state, $derived, $effect) make that implicit behavior explicit, and migrating is less about learning new syntax than about deciding which of your old $: blocks were actually side effects in disguise.
The thesis of this post: runes trade a little of Svelte 4's "it just works" charm for predictability, and the migration is worth doing incrementally because legacy syntax still compiles alongside it.
The concrete problem: a cart total
Take a small shopping-cart component. In Svelte 4 you'd write something like this:
<script>
let items = [];
$: total = items.reduce((sum, item) => sum + item.price * item.qty, 0);
$: if (total > 100) {
applyFreeShipping();
}
</script>Two $: blocks, two very different jobs. The first derives a value from state. The second performs an action when a condition flips. Svelte 4 treats them identically, which is exactly the ambiguity runes force you to resolve.
The runes version
In Svelte 5, the same component becomes:
<script>
let items = $state([]);
let total = $derived(items.reduce((sum, item) => sum + item.price * item.qty, 0));
$effect(() => {
if (total > 100) {
applyFreeShipping();
}
});
</script>Three deliberate choices:
$state([])declares reactive state. Deep mutations likeitems.push(...)are tracked through a proxy, so you no longer need the Svelte 4 habit of reassigning (items = [...items, newItem]) just to trigger an update.$derived(...)replaces the recomputation pattern.totalis a pure function ofitemsand recalculates when its dependencies change. Most of your old$:blocks belong here.$effect(...)is the escape hatch for actual side effects — DOM measurement, syncing a third-party widget, timers. It runs after the DOM updates.
The most common migration mistake
The trap is reaching for $effect everywhere because it feels closest to $:. Don't. If a $: block computed a value from other state, it should become $derived. Effects that set state create update chains that are harder to reason about and can cause extra renders. A useful rule of thumb during migration: try $derived first, and only drop to $effect when you're touching something outside Svelte's reactive graph.
Why the explicitness pays off
Two practical wins. First, runes work in .svelte.js and .svelte.ts modules, not just components. Shared reactive logic that used to require stores can now be a plain exported class or function using $state — no store contract, no $-prefix subscription syntax at the call site. Second, Svelte 5's fine-grained reactivity means updates are more targeted than Svelte 4's component-level invalidation; qualitatively, less of your component re-runs per change, though you should measure your own app before expecting a specific speedup.
Trade-offs and gotchas
A few honest costs:
- No mixing within a component. Runes mode and legacy mode can't coexist in one
.sveltefile — the compiler errors out. Convert a component fully or leave it alone. Across an app, though, legacy and runes components coexist fine, which is what makes incremental migration viable. - Proxy surprises.
$statevalues are proxies. Passing one to a library that expects a plain object (some charting or serialization libraries) can misbehave. Use$state.snapshot(state)orstructuredCloneat that boundary. - Lost brevity.
$: total = ...is shorter than the$derivedequivalent. You're paying a few characters for intent that's legible to any JS developer, including your future self.
How to verify a migrated component
Don't trust the compile step alone. After converting, exercise the reactive paths: mutate the state through whatever UI drives it (add an item, change a quantity) and confirm the derived value updates without manual reassignment. Then grep the file for leftover $: labels — any survivor means the component is half-migrated and likely in legacy mode. If you converted a $: block to $effect, ask once more whether it should have been $derived.
Start with one leaf component — something with a couple of $: blocks and no children depending on its internals. The migration is mechanical once you've made the one real decision each block demands: is this a value, or is this an effect?
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.