Svelte 4's reactivity was compiler magic that worked until it didn't: $: statements re-ran based on static dependency analysis of the assignment on the right-hand side, which meant reactivity broke in ways that were hard to explain to a newcomer — destructuring a reactive value, mutating an array in place, or moving a computation into a function all had subtly different rules. Svelte 5's runes replace that with something more explicit and, in my experience porting three mid-sized apps, considerably easier to reason about. They also finally put Svelte in the same conceptual family as Solid.js and Preact Signals, though the implementation underneath is quite different.
What runes actually are
$state, $derived, and $effect are not functions in the normal sense — they're compiler intrinsics that the Svelte compiler recognizes and transforms at build time. Write let count = $state(0) and the compiler rewrites reads and writes of count into calls against a reactive signal under the hood. You still write count++, not count.set(count.get() + 1). That's the core design bet of runes: keep the ergonomics of plain variable assignment, but make the dependency tracking explicit and runtime-based instead of relying on the compiler statically guessing what a $: block depends on.
<script>
let count = $state(0);
let doubled = $derived(count * 2);
$effect(() => {
console.log(`count changed to ${count}`);
// cleanup function, runs before next effect or on unmount
return () => console.log('cleaning up previous effect');
});
</script>
<button onclick={() => count++}>
{count} (doubled: {doubled})
</button>
Signals: the explicit primitive Solid and Preact expose
Solid.js and Preact Signals take a different stance: the signal itself is a first-class value you pass around, read with a function call, and write with a setter. In Solid, const [count, setCount] = createSignal(0) gives you count() to read and setCount(n) to write — the parentheses are not optional, and forgetting them is a common bug because count without a call is the signal object, not its value. Preact Signals similarly expose a .value accessor. The upside of this explicitness is that signals compose freely outside components — you can create one in a plain module, share it across files, and update it from anywhere without a compiler in the loop. Svelte 5 runes, by contrast, only work inside .svelte and .svelte.js/.svelte.ts files, because the compiler needs to see the rune calls to instrument them.
This is the practical dividing line. A Solid or Preact signal is just a JavaScript value — you can put one in a .ts file with zero build-tool awareness. A Svelte rune only becomes reactive inside files the Svelte compiler processes, which is why shared reactive state now lives in .svelte.js files rather than plain .js.
Migrating from $: to runes
The mechanical part of migration is usually straightforward: let x = 5 becomes let x = $state(5), and $: y = x * 2 becomes let y = $derived(x * 2). The part that actually takes time is auditing every place a $: block did something that wasn't a pure derivation — side effects tucked into reactive statements, which need to become $effect instead of $derived, and stores (writable, derived from svelte/store) that can often be deleted entirely once their job is replaced by a rune in a .svelte.js module.
// Svelte 4
<script>
export let items = [];
let total = 0;
$: total = items.reduce((sum, i) => sum + i.price, 0);
$: if (total > 1000) console.log('big cart');
</script>
// Svelte 5
<script>
let { items = [] } = $props();
let total = $derived(items.reduce((sum, i) => sum + i.price, 0));
$effect(() => {
if (total > 1000) console.log('big cart');
});
</script>
Note the split: the pure computation (total) becomes $derived, and the side effect (the console log) becomes its own $effect. Svelte 4's $: let you mix both in one reactive statement, which was convenient to write and a common source of confusion when a derivation accidentally triggered a side effect twice.
Cross-file reactive state: the real upgrade
The thing runes actually fix, more than the syntax, is sharing reactive state across files. In Svelte 4 that meant reaching for writable() stores and the $store auto-subscription syntax. In Svelte 5, a .svelte.js file can export a rune-backed object directly, and consuming components get the same fine-grained reactivity they'd get from local state — no subscribe/unsubscribe lifecycle to manage.
// store.svelte.js
export function createCart() {
let items = $state([]);
let total = $derived(items.reduce((s, i) => s + i.price, 0));
return {
get items() { return items; },
get total() { return total; },
add(item) { items.push(item); }
};
}
export const cart = createCart();
This is closer in spirit to how Solid or Preact signals get shared across a module, even though the mechanism (compiler-tracked class-like getters vs. explicit signal objects) is different underneath.
$state([]) returns a proxy — mutating it with .push() or index assignment triggers reactivity, unlike Svelte 4 where array mutation needed a reassignment trick (items = items) to be picked up. If you need a plain, non-proxied snapshot, use $state.raw() or $state.snapshot(items).
Wrapping up
Runes and signals are solving the same problem — fine-grained, push-based reactivity instead of Svelte 4's static compiler analysis or React's re-render-the-whole-component model — but they make different trade-offs about where the magic lives. Signals (Solid, Preact) are explicit values with an API; runes are compiler-recognized syntax that keeps assignment ergonomics but requires the file to go through the Svelte compiler. If you're migrating a Svelte 4 codebase, budget real time for the stores-to-runes conversion, not just the $: conversion — that's where the actual behavior changes live.
Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.