Svelte 5 Runes: Reactivity Out in the Open

Svelte 5 with runes shipped last October, and a year on it has settled. Runes make developers spell out the reactivity that Svelte previously inferred at compile time. The $-prefixed symbols ($state, $derived, $props, $effect) are compiler-level keywords, not functions; they need no imports and work in both .svelte and .svelte.ts files. This is the main adjustment for anyone coming from Svelte 3/4.

From “the compiler guesses” to “you say it plainly”

Svelte 4 reactivity leaned on let declarations and $: labels, worked out by static analysis at compile time. The upside was clean code; the downside was that the magic lived inside the compiler. Once a component grew and the logic wound around itself, you’d start guessing which variable actually triggered an update, and in what order the $: blocks ran. Compiling is not the same as understanding how your data flows.

Runes pull that layer into the open. let count = $state(0) declares a reactive value; count is still an ordinary number, you bump it with count++ like anything else, no separate setter API. Computed values go through $derived:

<script>
  let count = $state(0);
  let doubled = $derived(count * 2);
</script>

<button onclick={() => count++}>{doubled}</button>

Anything read inside $derived becomes its dependency; when a source changes the derived is marked dirty and recomputed the next time it’s read. The difference from Svelte 4’s $: is that reactivity stops being a side-effect of how you named a variable and becomes a primitive you deliberately apply. One less layer of guessing, a bit more predictability.

The extra explicitness comes with a handful of rules. $state on an object or array gives you a deeply reactive proxy: push and property writes both trigger updates, while a destructured value stops being reactive because JavaScript takes a snapshot at that point. Class instances are not proxied, so each field needs its own $state. Reasoning about how state flows is more reliable than memorising these as isolated API rules.

Against React/Vue, one fewer layer of abstraction

For anyone with DOM intuition, Svelte’s mental model reads smoothly: you write markup plus state, and the compiler emits code that touches the DOM directly, with no virtual DOM in between. React’s re-render model reruns the whole function component, which is why useMemo, useCallback, and dependency arrays exist to suppress recomputation. Much of that work deals with the framework’s execution model instead of the business problem.

Svelte 5 uses push-pull reactivity: when state changes, its dependants are notified immediately (the push), but a $derived is not recomputed until something reads it (the pull). If the new value is referentially identical to the old one, downstream updates are skipped. The dependency and recomputation rules stay explicit, while the compiler handles the bookkeeping that React leaves in manually annotated dependency lists.

Vue’s ref/reactive/computed are conceptually close to runes because all of them are explicit reactive primitives. Svelte omits the .value wrapper and virtual-DOM diff, leaving one fewer abstraction to track.

The real trade-off: module state retires stores

The most practical improvement is simpler state sharing across components. Previously this required Svelte stores with writable, subscriptions, and the $store auto-subscription syntax, or a custom solution. Runes allow reactive module state directly in a .svelte.ts file.

Take a shopping cart. The requirement is dull: share it across pages, keep it after a refresh. I used to open a store, wire subscribe into localStorage, then handle reading it back on init. A single .svelte.ts module absorbs all of that:

// cart.svelte.ts
function createCart() {
  let items = $state<Item[]>(load());
  $effect.root(() => {
    $effect(() => localStorage.setItem('cart', JSON.stringify(items)));
  });
  return {
    get items() { return items; },
    add: (i: Item) => { items.push(i); },
    clear: () => { items = []; },
  };
}
export const cart = createCart();

There’s a sharp edge here: you can’t just export let count = $state(0) and reassign it everywhere, because the compiler rewrites every reference to count, so cross-module reassignment won’t connect. So you either wrap it in an object exposed through getters, or hand out operations like add() / clear(). Know that one rule and the design falls out naturally.

The old stores remain available through svelte/store, but module state is usually more direct for new code and removes a layer of $ auto-subscription behaviour for the next maintainer to learn.

For teams that value a visible execution model over ecosystem size, Svelte 5 is a useful reference implementation. HTML remains the lead and reactivity stays visible instead of disappearing into another framework abstraction.

Notes

  • Runes replace Svelte 4’s compiler-guessed reactivity with the explicit $state / $derived / $props / $effect primitives, improving predictability.
  • The cost of explicitness is a few rules (deep proxy reactivity, destructuring drops reactivity, class fields marked individually); reason about state flow rather than memorising them.
  • Against React’s re-render model, Svelte has one fewer layer (no virtual DOM), which lowers the mental load for anyone with DOM intuition.
  • Shared state such as a cart works as .svelte.ts module state plus localStorage; a reassigned $state cannot be exported directly.
  • For those not chasing ecosystem size, Svelte 5 is a relatively clean reference implementation among modern frameworks, with HTML still in the lead.

Sheng’s take, drafted with Claude · part of the 2026-06-13 blog renovation, paint still drying.