DevLift
Back to Blog

Svelte 5 Runes: The New Reactivity Model Explained

Runes look like function calls but compile into signals, proxies and effects, so the fastest way to understand them is to read what svelte/compiler emits for each one.

Admin
September 2, 20266 min read2 views
Svelte 5 Runes: The New Reactivity Model Explained

Svelte 5 Runes: The New Reactivity Model Explained

I copied a derived value out of a blog post, dropped it into a component, and the page rendered this:

() => count * 2

Not the number. The arrow function, as text, in the DOM. No compiler error. No warning. Nothing in the console. The snippet was one character away from correct and the only feedback I got was a paragraph full of JavaScript source.

That is the thing about runes that trips people up. They look like function calls, so you reason about them like function calls, and then something that is obviously a function call turns out to be a compiler instruction with rules you cannot see from the call site. The fix is not to memorise the rules. It is to stop guessing and read what the compiler produces, because Svelte ships that as a public API.

npm i -D svelte@5.57.0

Everything below was produced by that version.

Eight lines in, twenty lines out

Here is a component with one of each of the four runes you will use daily.

<script>
  let { label } = $props();
  let count = $state(0);
  let double = $derived(count * 2);
  $effect(() => { console.log(double); });
</script>
 
<button onclick={() => count++}>{label}: {double}</button>

Feed it to the compiler directly. No bundler, no dev server:

import { compile } from 'svelte/compiler';
import fs from 'node:fs';
 
const source = fs.readFileSync('Counter.svelte', 'utf8');
const { js, warnings } = compile(source, {
  filename: 'Counter.svelte',
  generate: 'client',
  dev: false
});
 
console.log(js.code);
console.log(warnings.map((w) => w.code));

And here is what comes out, verbatim:

import 'svelte/internal/disclose-version';
import * as $ from 'svelte/internal/client';
 
var root = $.from_html(`<button> </button>`);
 
export default function Counter($$anchor, $$props) {
  $.push($$props, true);
 
  let count = $.state(0);
  let double = $.derived(() => $.get(count) * 2);
 
  $.user_effect(() => {
    console.log($.get(double));
  });
 
  var button = root();
  var text = $.only_child(button);
 
  $.template_effect(() => $.set_text(text, `${$$props.label ?? ''}: ${$.get(double) ?? ''}`));
  $.delegated('click', button, () => $.update(count));
  $.append($$anchor, button);
  $.pop();
}
 
$.delegate(['click']);

Read the correspondence and most of the mystery evaporates:

  • $state(0) became $.state(0). A signal. count is now a box, and every read of it in your code became $.get(count).
  • $derived(count * 2) became $.derived(() => $.get(count) * 2). Your expression got wrapped in an arrow by the compiler. You did not write that arrow, and that matters in about ten minutes.
  • $effect became $.user_effect — the user_ prefix separates your effects from the ones the compiler creates for the template, like $.template_effect below it.
  • label never became a variable at all. It is $$props.label, read fresh inside the template effect.
💡

The $. here is not a rune. It is the local alias for svelte/internal/client, chosen because $ is already a reserved prefix in Svelte source so there is no chance of a collision. Convenient, and briefly confusing the first time you read emitted output.

Rendering diagram...

The arrow you did not write

Since $derived wraps your expression in an arrow, passing it an arrow gives you two of them. Nothing stops you:

import { compile } from 'svelte/compiler';
 
const cases = {
  expr:   `<script>let a=$state(1); let b=$derived(a*2);</script>{b}`,
  arrow:  `<script>let a=$state(1); let b=$derived(()=>a*2);</script>{b}`,
  by:     `<script>let a=$state(1); let b=$derived.by(()=>a*2);</script>{b}`,
  byExpr: `<script>let a=$state(1); let b=$derived.by(a*2);</script>{b}`,
  zero:   `<script>let b=$derived();</script>{b}`,
  two:    `<script>let b=$derived(1,2);</script>{b}`
};
 
for (const [name, src] of Object.entries(cases)) {
  try {
    const { warnings } = compile(src, { filename: 'X.svelte' });
    console.log(name, 'OK', warnings.map((w) => w.code));
  } catch (e) {
    console.log(name, 'ERROR', e.code, '::', e.message.split('\n')[0]);
  }
}
expr   OK []
arrow  OK []
by     OK []
byExpr OK [ 'state_referenced_locally' ]
zero   ERROR rune_invalid_arguments_length :: `$derived` must be called with exactly one argument
two    ERROR rune_invalid_arguments_length :: `$derived` must be called with exactly one argument

arrow is clean. No error, no warning. The compiler wrapped the arrow, so b is a derived whose value is a function — and it updates correctly, which makes it worse, because the value is right and the type is wrong. Watching it over three mutations with flushSync:

initial   { count: 1,  dExpr: 2,   dBy: 2,   dArrow: '[Function] -> 2'   }
after =5  { count: 5,  dExpr: 10,  dBy: 10,  dArrow: '[Function] -> 10'  }
after =50 { count: 50, dExpr: 100, dBy: 100, dArrow: '[Function] -> 100' }

Then {b} stringifies it, and you get an arrow function printed on your page. $derived takes an expression; $derived.by takes the function. The reverse mistake — $derived.by(a*2) — at least earns you a state_referenced_locally warning, which is the compiler saying you read state outside a tracked context and froze it.

Where the proxy stops

$state on a primitive is a signal. On an object it is a proxy, and you can see the difference in the emitted code:

<script>
  let o = $state({ x: 1 });
  let r = $state.raw({ x: 1 });
  let d = $derived.by(() => o.x + r.x);
</script>
<span>{d}</span>
export default function X($$anchor) {
  let o = $.proxy({ x: 1 });
  let r = { x: 1 };
  let d = $.derived(() => o.x + r.x);
  // ...template setup...
}

That component also compiles with one warning, state_referenced_locally, pointing at r.x — the compiler telling you it can see a non-reactive read inside a tracked context.

$state.raw emits nothing at all here — a bare object literal, because r is never reassigned so it needs no signal. No proxy means no property-level tracking, which means you reassign the variable or nothing happens.

The docs say state is "proxified recursively until Svelte finds something other than an array or simple object (like a class or an object created with Object.create)". Worth checking where exactly that stops, because the boundary is not where people expect:

obj.nested.deep++          derived 1 -> 2     nested objects: tracked
obj.list[0].k++            derived 1 -> 2     objects inside arrays: tracked
obj.list.push({ k: 9 })    length  1 -> 2     mutating array methods: tracked
raw.nested.deep++          derived unchanged  $state.raw: not tracked
raw = { ... }              derived -> 100     ...but reassignment is
$state(new Plain()).n++    derived unchanged  class instance: NOT proxied
new Runed().n++            derived 0 -> 1     class field declared $state: tracked

Line five is the one that bites. $state(someClassInstance) compiles, runs, and quietly does nothing, because the proxy stops at the class boundary. Reactive classes work the other way round — you put the rune on the field, and the instance needs no wrapper:

class Cart {
  items = $state([]);
  total = $derived(this.items.reduce((n, i) => n + i.price, 0));
}
⚠️

$state.frozen was renamed. Using it now fails at compile time with rune_renamed: "$state.frozen is now $state.raw". If you are reading a tutorial that mentions it, the rest of that tutorial is old too.

Props, and the copy that goes stale

Received wisdom says destructuring $props() costs you reactivity. The emitted code says otherwise — label compiled straight to $$props.label, read inside the template effect, fresh every time. So I mounted a parent in jsdom and bumped it twice:

<script>
  let { value } = $props();
  let snapshot = value;
  let dbl = $derived(value * 2);
</script>
<span id="destructured">{value}</span>
<span id="snapshot">{snapshot}</span>
<span id="derived">{dbl}</span>
n=1 -> destructured=1  snapshot=1  derived=2
n=2 -> destructured=2  snapshot=1  derived=4
n=3 -> destructured=3  snapshot=1  derived=6

The destructured binding tracks. snapshot is the one that dies at 1, and it dies because a plain let copy is a plain let copy — the same thing that would happen in any language. Props with a default or a $bindable compile differently again, into accessor functions:

let b = $.prop($$props, 'b', 3, 2),
    c = $.prop($$props, 'c', 11, 0);
// used as b(), c()

So three shapes, one rule: read the prop where you need it, do not park it in a variable.

Effects stop tracking at the first await

$effect re-runs when a tracked dependency changes, and the tracking is synchronous. Anything you read after a microtask boundary is invisible to it. An effect that reads a synchronously and b inside a .then():

pre sees a=0
sync-read a=0
  async-read b=0
--- mutate b (only read after the await) ---
--- mutate a (read synchronously) ---
pre sees a=1
sync-read a=1
  async-read b=1
--- set a to the same value again ---
--- stop the $effect.root, then mutate a ---

Mutating b produces nothing. Mutating a re-runs the effect, which then re-reads b and sees the new value — so the dependency looks like it works, right up until b changes on its own and the screen does not move. Two other things fall out of the same log: assigning an equal value is a no-op, and the teardown returned by $effect.root really does stop everything.

Effects also need an owner. This compiles fine and blows up on import:

// counter.svelte.js
let count = $state(0);
$effect(() => console.log(count));
Svelte error: effect_orphan
`$effect` can only be used inside an effect (e.g. during component initialisation)

Module scope is not a component lifetime, so there is nothing to clean the effect up. Wrap it in $effect.root and you get a teardown function to call yourself.

The filename is part of the syntax

That file is counter.svelte.js, not counter.js, and swapping the two changes what runs. The bundler plugin only routes .svelte.js and .svelte.ts through compileModule. Give a rune to a plain .js and nothing transforms it, so it reaches the runtime as an undeclared global:

ReferenceError: $state is not defined

The compiler API itself will accept any filename you hand it — compileModule(src, { filename: 'plain.js' }) succeeds — so this is not something compile() will catch for you. It is a bundler convention, and the failure surfaces in the browser.

What the old model was doing

The same emitted-code trick explains Svelte 4. Here is export let plus a reactive label:

<script>
  export let count = 0;
  let double;
  $: double = count * 2;
</script>
<span>{double}</span>
import 'svelte/internal/flags/legacy';
 
let count = $.prop($$props, 'count', 8, 0);
let double = $.mutable_source();
 
$.legacy_pre_effect(() => ($.deep_read_state(count())), () => {
  $.set(double, count() * 2);
});
 
$.legacy_pre_effect_reset();

$.deep_read_state is the whole story. The old compiler could not know statically what your statement depended on, so it emitted a pass that walks the referenced values at runtime and subscribes to whatever it finds. $.mutable_source exists because assignment, not signal identity, was the update trigger. Both disappear in runes mode — $.derived knows its dependencies because you wrote them inside the expression.

Legacy syntax still compiles in 5.57.0. What does not compile is mixing:

legacy $: + export let   OK
$: inside a runes component   ERROR legacy_reactive_statement_invalid
export let beside $state      ERROR legacy_export_invalid
on:click in a runes component  OK, warning event_directive_deprecated

Runes mode is per component, switched on by the first rune in the file. That is why a Svelte 4 codebase keeps building after the upgrade and then throws legacy_export_invalid the moment someone adds one $state to an old file.

You do not have to convert by hand. migrate is exported from svelte/compiler, and it is what npx sv migrate svelte-5 calls:

import { migrate } from 'svelte/compiler';
console.log(migrate(source, { filename: 'Counter.svelte' }).code);

On the component above:

<script>
  /**
   * @typedef {Object} Props
   * @property {number} [count]
   */
 
  /** @type {Props} */
  let { count = 0 } = $props();
  let double = $derived(count * 2);
</script>
 
<span>{double}</span>

It writes the JSDoc, folds the label into $derived, and — on a component using stores — leaves $count alone, because store auto-subscription is still supported inside runes mode.

Running the arrow mistake through the server renderer

Back to where this started. svelte/server renders a component to a string with no DOM, which makes the bug visible without a browser:

import { render } from 'svelte/server';
import Ssr from './Ssr.server.mjs';
 
console.log(render(Ssr, { props: { label: 'count' } }).body);
<script>
  let { label = 'n' } = $props();
  let count = $state(3);
  let double = $derived(count * 2);
  let arrow  = $derived(() => count * 2);
</script>
<p>{label} {count} {double}</p>
<p>{arrow}</p>
<!--[--><p>count 3 6</p> <p>() => count * 2</p><!--]-->

Comments (0)

No comments yet. Be the first to share your thoughts!

Related Articles

Do [1,4] and [4,5] Overlap? Answer That First
LeetCode 56 merges [1,4] and [4,5]; LeetCode 435 says they do not overlap at all. Closed versus half-open ends is the one real decision in interval problems, and most interval bugs come from never making it.
AdminAugust 11, 20268 min read
Client-side validation is UX. Server-side validation is security. And a schema is not an auth guard — here's the fix that still ships an account takeover, and how to catch it in review.
AdminAugust 5, 20269 min read
A monotonic stack maintains elements in order and pops when that order breaks — finding the next greater element for every popped value in O(n) total.
AdminAugust 3, 20265 min read