• #svelte
  • #sveltekit
  • #runes
  • #beginners

Svelte 5 Runes Explained From Scratch.

A friendly, in-depth guide to $state, $derived, and $effect, and why Svelte 5 moved away from implicit reactivity to give you explicit, predictable state.

Kituu · 9 min read ·

Let's Start With The Problem Why Runes Exist At All

Before runes make any sense, you need to feel the confusion they replace. Not the "yeah, Svelte reactivity is a little magic" kind of familiarity, but the kind where you've actually stared at a let declaration wondering why it updates the DOM in one file and does absolutely nothing in another.

So here's the scenario, stripped all the way down.

In Svelte 4, this looked like reactive state:

And it was reactive, but only inside a .svelte component file, and only because the compiler was quietly rewriting your code behind the scenes. The moment you tried to pull that same logic into a plain .ts file to reuse it, the magic vanished:

counter.ts

No error. No warning. Just silence, and a UI that never updates. The reactivity only ever existed inside the special compiler context of a component, which meant sharing reactive logic across files meant reaching for stores, writable, $store syntax, and a whole second mental model sitting next to your "normal" variables.

You end up with two systems in your head at once: plain variables that Svelte quietly reacts to inside components, and stores that Svelte reacts to everywhere else. Runes exist to collapse that back into one system.


The Core Idea Reactivity You Can See

A rune is just a function-looking symbol, prefixed with $, that tells Svelte "this value is reactive, track it explicitly." Nothing is inferred from context anymore. If you want reactive state, you say so.

Here's that same counter, written with $state:

Visually this looks almost identical to the old version, but the difference is everything. $state isn't a real function that runs at runtime and returns a number. It's a signal to the Svelte compiler: "wrap this value so that reading and writing it is tracked." And critically, this works the exact same way whether it's sitting in a .svelte file or a plain .svelte.ts file.

counter.svelte.ts

That's a fully reactive counter, shareable across your entire app, with no stores, no $ subscription syntax, no separate mental model. Just a variable that happens to be tracked.


The .svelte.ts File Extension Is Not A Typo

A quick side note before going further. Runes only work inside .svelte files and files ending in .svelte.js or .svelte.ts. That extra .svelte segment isn't decoration, it's what tells the compiler "apply rune transformations here."

store.ts
store.svelte.ts

If you extract reactive logic into its own file and Svelte suddenly complains that $state doesn't exist, this is almost always the reason. Rename the file.


$derived Values That Follow Other Values

Plenty of state in a real app isn't state you set directly, it's state computed from other state. In Svelte 4 this was a reactive statement:

The $: label syntax worked, but it was a special parser trick, not a real JavaScript construct, and it re-ran the entire statement on every dependency change with no way to see exactly what it depended on just by reading it.

$derived replaces this with something that reads like a normal expression:

doubled isn't recalculated on some vague schedule. It's recalculated precisely when count changes, because Svelte tracked the read of count inside the $derived expression. Change the dependency, and the derived value updates automatically, everywhere it's used.

For anything more involved than a single expression, $derived.by takes a function body:

Same idea, just room for real logic instead of a single expression.


$effect Reacting To Change, Not Computing From It

$derived gives you a value. $effect gives you a side effect, something that runs in response to a change but doesn't produce a value anyone reads.

Every time count changes, this callback re-runs. Svelte figures out what to track the same way it does for $derived, by watching what gets read inside the function during its first run.

This is the rune people reach for too early. A common mistake, coming from Svelte 4's $: syntax, is using $effect to compute a value:

This runs, but it's the wrong tool. doubled should be a $derived value, not state kept in sync by an effect. Effects are for genuine side effects: writing to localStorage, calling an external API, manually touching the DOM, logging. If you're only computing a value from other values, reach for $derived, not $effect.


Let's Build A Real Thing A Reactive Form Validator From Scratch

You've almost certainly built a form with some kind of validation state. Let's build one properly with runes, because this is where $state and $derived actually work together.

The Goal

Track an email input, validate it as the user types, and derive both an error message and a boolean for whether the form can submit.

Building It

form.svelte.ts

Let's pull this apart:

  • email is the one piece of real state, everything else follows from it
  • isValidEmail is derived, it never gets set directly, it's recalculated whenever email changes
  • errorMessage derives from isValidEmail and email together, chaining derived values on top of each other
  • canSubmit derives from isValidEmail again, showing that derived values can be reused as building blocks for other derived values

None of this needed a store, a subscription, or a manual .set() call anywhere.

Type into the input, and the error message and button state update on their own, driven entirely by one $state value and a chain of $derived values built on top of it.


Compare This To The Store Version

It's worth seeing the contrast directly, because this is exactly the kind of code runes were built to replace.

form.ts

Functionally the same result, but notice the friction. Every derived value has to explicitly list its dependencies in an array. Every usage in the template needs the $ prefix to auto-subscribe. Chaining derived values on other derived values means nesting arrays of dependencies inside each other. The rune version reads like plain JavaScript because, mechanically, it almost is.


Bringing It All Together

Here's the full journey, end to end:

  1. Runes exist because Svelte 4's reactivity only worked inside .svelte component files, which meant a separate system, stores, for sharing reactive logic anywhere else
  2. $state marks a variable as reactive, explicitly, and works the same in .svelte files and .svelte.ts files alike
  3. .svelte.ts and .svelte.js file extensions are what unlock runes outside component files, this isn't optional naming
  4. $derived computes a value from other reactive values, and updates automatically whenever those values change
  5. $effect runs a side effect in response to change, it doesn't produce a value, don't use it to compute one
  6. Derived values can be chained on top of each other, building up complex reactive logic from small, readable pieces
  7. The result reads like ordinary JavaScript, because runes let Svelte track reactivity through real assignment and real function calls, not through a special-cased syntax bolted on top

Once runes click, Svelte code stops looking like it belongs to a special file type with special rules. $state is just a variable. $derived is just a value that depends on other values. You're not memorizing when reactivity applies anymore, you're just writing code, and Svelte is tracking it as you go.

That shift, from remembering where the magic works to just writing normal code, is the real unlock here.