Svelte 5 runes - the complete guide
- Published
- Updated
On this page
- What are Svelte 5 runes
- Why runes exist
- $state — reactive values
- $derived and $derived.by
- $effect and its variants
- $derived vs $effect — the decision tree
- $props, $bindable, and component I/O
- $inspect and $host
- Runes outside components
- Runes vs stores — are stores deprecated
- Migrating from Svelte 4 to Svelte 5
- Common pitfalls
- Wrapping up
Svelte 5 runes are compiler-directive symbols ($state, $derived, $effect, $props, plus variants) that make reactivity explicit and portable across .svelte, .svelte.js, and .svelte.ts files. They replace Svelte 4's let, $:, export let, and most store use cases with one unified model.
This is the complete guide: every rune, every variant, when to reach for each, and how they compile to signal-like primitives under the hood. If you're coming from Svelte 4, the migration matrix at the end covers every change you'll hit.
What are Svelte 5 runes
Runes are symbols with a $ prefix that tell the Svelte compiler how a value should behave. They look like function calls ($state(0), $derived(x * 2)) but they aren't: you can't import them, can't assign them to variables, and can't pass them around. They're language-level keywords the compiler intercepts at build time.
This is the shift from Svelte 4. Reactivity used to be a set of heuristics the compiler inferred from your code: a top-level let was reactive, a $: label was a derivation, stores got a magic $ prefix when you read them. In Svelte 5, you declare the intent.
The full list, grouped by job:
$state,$state.raw,$state.snapshot,$state.eager: reactive state$derived,$derived.by: computed values$effect,$effect.pre,$effect.tracking,$effect.pending,$effect.root: side effects$props,$props.id,$bindable: component inputs$inspect,$inspect.trace,$host: dev tools and custom elements
Don't memorize that list. 90% of what you'll write is $state, $derived, $effect, $props. The rest sits on the shelf for specific cases.
Runes mode kicks in the moment you use any rune in a file. No config, no opt-in flag. That is how you can adopt them file-by-file rather than as a big-bang rewrite.
Why runes exist
Svelte 4's reactivity worked by pattern-matching. The compiler looked at your component, saw let count = 0, and assumed you wanted it reactive. It saw $: doubled = count * 2, and wired up the dependency graph from the variables visible in that expression.
That approach was elegant in a 30-line component. It fell apart at scale for three specific reasons.
Boundaries. Reactivity only worked at the top level of a .svelte file. Move a reactive chunk into a plain .js helper and it silently stopped updating. Refactoring was a quiet trap.
Dependency tracking was compile-time. $: only saw variables in the literal expression. The moment you extracted logic into a function, the compiler lost track of what depended on what, and your "reactive" value became stale.
Stores were a patch, not a solution. To share reactive state across files, you reached for writable/readable/derived, an entire parallel API with its own subscription contract, $-prefix read magic, and manual .set/.update calls. It worked, but it was two reactivity models living in one codebase.
Runes unify all three. Dependency tracking is runtime now (signals under the hood), so extracting logic into functions doesn't break anything. The same rune works identically in a .svelte file, a .svelte.ts module, or a class field. Stores aren't deprecated, but most of the reasons you used them are gone.
The trade-off the Svelte team acknowledged openly: let count = $state(0) looks less magical than let count = 0. That's the point. Explicit reactivity beats magic once your codebase has 200 components.
$state — reactive values
The simplest rune. $state(value) wraps any value in a reactive cell: writing to it schedules UI updates, reading it registers a dependency.
<script lang="ts">
let count = $state(0);
</script>
<button onclick={() => count++}>clicks: {count}</button>
Here is what that compiles to:
import * as $ from 'svelte/internal/client';
var root = $.from_html(`<button> </button>`);
export default function App($$anchor) {
let count = $.state(0);
var button = root();
var text = $.child(button);
$.reset(button);
$.template_effect(() =>
$.set_text(text, `clicks: ${$.get(count) ?? ''}`)
);
$.delegated('click', button, () => $.update(count));
$.append($$anchor, button);
}
$.delegate(['click']);
No magic, just a compiler swapping plain JS for signal-aware calls. $state(0) becomes $.state(0), a signal cell. Reads compile to $.get(count) (which registers a dependency). count++ compiles to $.update(count) (read, increment, write). The DOM update is wrapped in $.template_effect so it re-runs when count changes.
There is no .set(), no .value. You read and write it like a normal variable. The compiler rewrites both sites to signal-aware code. The language hasn't changed, only the semantics.
Deep reactivity is on by default
Plain objects and arrays passed to $state become deep proxies. Mutating a nested property or pushing to an array triggers an update:
const user = $state({ name: 'Justin', tags: ['sv', 'ts'] });
user.name = 'J'; // triggers update
user.tags.push('db'); // triggers update
This is the biggest behavioral difference from Svelte 4's writable store, where mutation didn't trigger anything. You had to .set() a whole new object.
$state.raw when you do not want the proxy
Proxies have a cost. For large, frozen-ish objects (config blobs, parsed JSON from an API response you do not plan to mutate), $state.raw skips the proxy:
let payload = $state.raw(bigResponse);
payload.foo = 'x'; // no update
payload = { ...payload, foo: 'x' }; // reassignment triggers update
$state.snapshot for escape hatches
Some APIs choke on proxies (structuredClone, some JSON serializers). $state.snapshot(value) returns a plain static copy:
await fetch('/api/save', {
method: 'POST',
body: JSON.stringify($state.snapshot(user))
});
$state.eager for user-input responsiveness
Newer in Svelte 5: when state updates happen inside await, Svelte batches them for efficiency. That is usually what you want, but it means the UI can lag a frame behind a user action. $state.eager forces the read to flush synchronously:
<a aria-current={$state.eager(pathname) === '/' ? 'page' : null}>home</a>
Use it sparingly. Only when a user-visible value must reflect the latest update immediately.
The destructuring gotcha
Destructuring a $state object gives you plain values, not reactive bindings:
const user = $state({ name: 'J' });
const { name } = user; // name is a plain string now
Destructuring breaks reactivity. Access through the original object (user.name) or reach for getter patterns if you need to pass a reactive read somewhere.
$derived and $derived.by
$derived(expr) computes a value from reactive state. It recomputes lazily when its dependencies change and when you actually read it.
let count = $state(0);
let doubled = $derived(count * 2);
let isEven = $derived(count % 2 === 0);
The expression has to be side-effect-free. The compiler rejects mutations inside a $derived:
let next = $derived(count++); // compile error
That constraint is what makes deriveds safe to recompute at will.
Here is what a minimal $derived compiles to:
import * as $ from 'svelte/internal/client';
var root = $.from_html(`<p></p>`);
export default function App($$anchor) {
let count = 0;
let doubled = $.derived(() => count * 2);
var p = root();
p.textContent = `0 / ${$.get(doubled) ?? ''}`;
$.append($$anchor, p);
}
$derived(count * 2) compiles to $.derived(() => count * 2), a thunk the runtime evaluates lazily when something reads $.get(doubled). Notice how count is a plain let, not a signal: the compiler saw this snippet never writes to count, so it stripped the $state layer. Runes are not dumb wrappers. If a value is effectively a constant, the wrapper gets erased.
$derived.by for multi-statement logic
When the derivation does not fit in one expression, use $derived.by with a function body:
let total = $derived.by(() => {
let sum = 0;
for (const n of numbers) sum += n;
return sum;
});
$derived(expr) is shorthand for $derived.by(() => expr). Same rules apply: the function must be pure.
Destructuring is fine here
Unlike $state, destructuring a $derived gives you individually reactive values:
const { x, y } = $derived(getPoint(t));
// x and y are each their own reactive derived
You can override a derived
Since Svelte 5.25, a $derived declared with let can be reassigned. Useful for optimistic UI:
let likes = $derived(post.likes);
async function like() {
likes += 1; // optimistic
try { await api.like(post.id); }
catch { likes -= 1; } // rollback
}
The override sticks until the underlying state changes again, at which point the derivation takes over. Declare with const if you want to forbid this.
The referential-identity optimization
If a $derived recomputes to a value that is === to its previous output, Svelte skips notifying downstream consumers. That means cheap deriveds in hot paths are fine. The cost is only paid when the value actually changes.
$effect and its variants
$effect(fn) runs a function after the DOM updates, and re-runs it whenever its synchronous dependencies change. It is how you bridge reactive state to anything outside the Svelte render cycle: third-party libraries, logging, browser APIs, timers.
<script lang="ts">
let count = $state(0);
$effect(() => {
document.title = `count: ${count}`;
});
</script>
Add a button to trigger re-runs and this compiles to:
import * as $ from 'svelte/internal/client';
var root = $.from_html(`<button> </button>`);
export default function App($$anchor, $$props) {
$.push($$props, true);
let count = $.state(0);
$.user_effect(() => {
document.title = `count: ${$.get(count)}`;
});
var button = root();
var text = $.child(button, true);
$.reset(button);
$.template_effect(() => $.set_text(text, $.get(count)));
$.delegated('click', button, () => $.update(count));
$.append($$anchor, button);
$.pop();
}
$.delegate(['click']);
$effect(fn) compiles to $.user_effect(fn). Inside the function, $.get(count) registers count as a dependency on first run. When the button handler calls $.update(count), Svelte schedules the user_effect to re-run, which writes the new title. That is the full mechanism.
Reach for $effect when you are syncing with the outside world, not when you are computing a value. For computing, always use $derived.
Cleanup
Return a function from an effect to clean up before the next run and on unmount:
$effect(() => {
const id = setInterval(() => count++, 1000);
return () => clearInterval(id);
});
Dependency rules
Only synchronous reads register as dependencies. That means:
$effect(() => {
console.log(count); // tracked
setTimeout(() => console.log(count), 100); // not tracked
Promise.resolve().then(() => console.log(count)); // not tracked
});
If you need to read state without registering it as a dependency, wrap it in untrack:
import { untrack } from 'svelte';
$effect(() => {
analytics.track('count_changed', {
now: count,
prev: untrack(() => previous)
});
});
$effect.pre
Runs before DOM updates are flushed. Useful when you need to read the DOM pre-update state (e.g. scroll position) before it reflows:
$effect.pre(() => {
autoscroll = atBottom();
});
$effect.root
The escape hatch for effects outside a component lifecycle. A normal $effect needs to run inside a tracking scope, normally the component's, and Svelte cleans it up automatically when the component unmounts. For singletons or manually-managed instances (a Settings class shared across the app, say), wrap your effects in $effect.root and clean up yourself:
class Settings {
theme = $state<'light' | 'dark'>('light');
#cleanup: () => void;
constructor() {
this.#cleanup = $effect.root(() => {
$effect(() => {
localStorage.setItem('theme', this.theme);
});
});
}
destroy() { this.#cleanup(); }
}
$effect.tracking and $effect.pending
Niche. $effect.tracking() returns true if the code is currently inside a reactive scope, useful when writing rune-aware helpers. $effect.pending() returns the count of unresolved promises in the current async boundary, for showing spinners. You will go years without touching either.
$derived vs $effect — the decision tree
The single most common mistake with runes is reaching for $effect when a $derived would do. The rule is simple:
Computing a value? $derived. Doing a side effect? $effect.
If the goal is to produce a new value that depends on state, it is a derivation. Syncing state with state is never a job for $effect:
// wrong
let count = $state(0);
let doubled = $state(0);
$effect(() => {
doubled = count * 2;
});
// right
let count = $state(0);
let doubled = $derived(count * 2);
The wrong version has two reactive values that can be out of sync during a render pass. The right version has one source of truth and a read-through cache.
If the goal is to reach out of Svelte's world (write to localStorage, update document.title, call a third-party library, fire analytics), it is an effect. Side effects cannot be expressed as pure functions, so $derived cannot represent them.
Decision tree:
Does this produce a value my template or other code will read? Use
$derived.Does it need to talk to the DOM, the network, or a library outside Svelte? Use
$effect.Both? Use
$derivedfor the value,$effectfor the side effect. Don't collapse them.
If you find yourself assigning to a $state inside a $effect, stop. You want a $derived.
$props, $bindable, and component I/O
Props come in as a single $props() call that you destructure:
<!-- button.svelte -->
<script lang="ts">
type Props = {
label: string;
variant?: 'primary' | 'ghost';
onclick?: () => void;
};
let { label, variant = 'primary', onclick }: Props = $props();
</script>
<button class={variant} {onclick}>{label}</button>
This replaces Svelte 4's export let soup, $$props, and $$restProps with a single typed destructure. One call, typed at the destructure site, no magic identifiers.
Rest props and renames
let { class: klass, ...rest } = $props();
// spread rest onto the root element
Renaming is how you handle reserved words (class, for) without the Svelte 4 export { x as class } workaround.
$bindable for two-way data flow
By default, props are read-only. Mutating them throws an ownership_invalid_mutation warning in dev. When a parent wants a child to write back (form inputs, controlled pickers), the child opts in with $bindable:
<!-- fancy-input.svelte -->
<script lang="ts">
let { value = $bindable('') }: { value?: string } = $props();
</script>
<input bind:value />
Then the parent uses bind::
<FancyInput bind:value={message} />
The parent does not have to bind. Passing value="static" still works; $bindable just allows binding. Reach for it sparingly; two-way data flow gets hard to trace once more than one component writes to the same state.
$props.id for stable instance IDs
For ARIA attributes that need to link a label to an input, $props.id() returns a stable ID that survives SSR hydration:
<script lang="ts">
const uid = $props.id();
</script>
<label for="{uid}-email">Email</label>
<input id="{uid}-email" type="email" />
Cleaner than Math.random() tricks, SSR-safe.
$inspect and $host
Two specialized runes for narrow jobs.
$inspect for debugging
$inspect(value) is console.log with dependency tracking. It re-logs whenever any of its reactive arguments change:
let count = $state(0);
$inspect(count); // logs "init 0", then "update 1", "update 2", ...
It tracks deeply, so inspecting a state object logs on nested mutations too. It is a no-op in production builds, so leaving it in committed code is safe (if a little sloppy).
Chain .with(callback) to replace the default console.log:
$inspect(count).with((type, value) => {
if (type === 'update') debugger;
});
$inspect.trace() (Svelte 5.14+) tells you why an effect or derived re-ran. Drop it as the first statement in the function body and dev tools will log which reactive value triggered the run:
$effect(() => {
$inspect.trace('cart-sync');
syncCart(cart);
});
$host for custom elements
$host() returns the host DOM element when the component is compiled as a custom element. It is how you dispatch custom events outward:
$host().dispatchEvent(new CustomEvent('ready'));
If you are not building custom elements, you will never touch $host.
Runes outside components
Any file ending in .svelte.js or .svelte.ts gets the runes compiler. That is how you share reactive state across components without a store.
The gotcha that sends people searching
The thing that looks obvious and does not work:
// count.svelte.ts
export let count = $state(0); // looks right, is not
<!-- a.svelte -->
<script lang="ts">
import { count } from './count.svelte';
</script>
<button onclick={() => count++}>+1</button>
This compiles, but the button does not update other components. Runes track reactivity through object references and class fields, not through ES module bindings. When a.svelte writes to count, it is writing to a local copy, not the original cell.
You have to export an object or class, never a raw reactive primitive.
Pattern 1: exported reactive object
// user.svelte.ts
export const user = $state({ name: '', email: '' });
<script lang="ts">
import { user } from '$lib/user.svelte';
</script>
<input bind:value={user.name} />
Mutating user.name propagates everywhere because the reference is shared.
Pattern 2: getter/setter functions
Hide the primitive, expose a controlled API:
// counter.svelte.ts
let count = $state(0);
export const getCount = () => count;
export const increment = () => count++;
Components call getCount() to read and increment() to mutate. The primitive stays scoped to the module.
Pattern 3: class with $state fields
For anything with more than one field or non-trivial logic:
// cart.svelte.ts
type CartItem = { id: string; price: number };
export class Cart {
items = $state<CartItem[]>([]);
total = $derived(this.items.reduce((sum, i) => sum + i.price, 0));
add(item: CartItem) { this.items.push(item); }
clear() { this.items = []; }
}
export const cart = new Cart();
Class fields declared with $state are reactive, and the exported instance is a stable reference, so consumers see updates. This is the pattern to reach for once you start wrapping API clients, auth stores, or anything stateful that needs methods.
Runes vs stores — are stores deprecated
No. writable, readable, and derived from svelte/store are still shipped, still maintained, still documented. But the reasons to reach for them got narrow.
Use runes for 95% of what you used to use stores for. Component state, shared app state, derived values, effects. All runes now.
Keep stores for three specific cases:
RxJS or async-stream interop. The
$storeauto-subscribe syntax in templates works with anything that implements the store contract, including Observables. Wrapping runes for this is more work than just using the store.Third-party libraries that expect stores. Plenty of existing SvelteKit-era packages export stores. Wrapping them to feed into runes adds friction with no benefit.
Pub/sub with explicit subscription control. If you are building something that needs fine-grained lifecycle control over subscriptions,
writablewith.subscribe()is still the cleanest shape.
SvelteKit's own built-in stores ($page, $navigating, $updated) were migrated to runes-compatible equivalents (page, navigating, updated from $app/state) in SvelteKit 2. The old $app/stores imports still work, but new code should use the rune-based API.
One rule of thumb: if you are writing new code and a rune can do the job, use the rune. You get universal reactivity for free, the compiled output is smaller, and you do not have to explain to a new developer why your app has two reactivity systems living in the same repo.
Migrating from Svelte 4 to Svelte 5
Svelte 5 runs your Svelte 4 components unchanged. Legacy mode is real and stable. You can adopt runes file-by-file. The official migration script (bunx sv migrate svelte-5) handles the mechanical parts; the judgment calls are still yours.
Reactivity
let count = 0→let count = $state(0)$: doubled = count * 2→let doubled = $derived(count * 2)$: { sideEffect(count) }→$effect(() => sideEffect(count))beforeUpdate(fn)→$effect.pre(fn)afterUpdate(fn)→$effect(fn)
Props
export let foo→let { foo } = $props()export let foo = 'default'→let { foo = 'default' } = $props()$$props→let props = $props()$$restProps→let { foo, ...rest } = $props()export { x as class }→let { class: x } = $props()Any
export letbindable →let { value = $bindable() } = $props()
Events
on:click={handler}→onclick={handler}on:click|preventDefault={handler}→ calle.preventDefault()inside the handleron:click|capture→onclickcapture={handler}createEventDispatcher→ callback props (let { onfoo } = $props(); onfoo(data))Parent listening:
<Comp on:foo={handler} />→<Comp onfoo={handler} />Event forwarding:
<button on:click>→let { onclick } = $props(); <button {onclick}>
Slots and snippets
<slot />→{@render children()}<slot name="header" />→{@render header()}withlet { header } = $props()Parent:
<div slot="header">...</div>→{#snippet header()}<div>...</div>{/snippet}Slot data down:
<slot item={x} />→{@render row(x)}Slot data up (parent):
let:item→ snippet parameter{#snippet row(item)}...{/snippet}
Full treatment of snippets in Svelte snippets replacing slots.
Component lifecycle
new App({ target, props })→mount(App, { target, props })app.$destroy()→unmount(app)app.$set({ foo: 1 })→ mutate a$stateobject passed as propsapp.$on('event', fn)→ callback propSSR:
Component.render({ props })→render(Component, { props })fromsvelte/server
The surprises
A few changes are not obvious and bite during migration:
childrenis a reserved prop name, so you cannot have a prop calledchildrenthat isn't snippet content.<svelte:component this={X}>is no longer needed; components are dynamic by default.The
accessorsandimmutablecomponent options are ignored in runes mode.Class-field reactivity does not work by default. Assigning
obj.foo = xin a plain class will not trigger updates. Declare class fields with$stateexplicitly.
Common pitfalls
Most rune bugs trace back to five mistakes. Knowing them in advance will save you a long afternoon of confused logging.
Destructuring state loses reactivity
const user = $state({ name: 'J' });
const { name } = user; // plain string now
Destructured bindings are plain values. Either read through user.name or use a getter function when you need to pass a reactive read.
Syncing state with $effect instead of $derived
// wrong
$effect(() => {
doubled = count * 2;
});
Anything that produces a value from state is a $derived. If you reach for $effect, ask yourself: am I computing (derived) or doing (effect)? The compiler will not stop you from misusing $effect, but your app will have subtle timing bugs.
Exporting a reactive primitive
// counter.svelte.ts
export let count = $state(0); // broken across modules
Export an object, class, or getter/setter pair. Never a raw primitive.
Thinking $state.raw is an optimization
$state.raw is for values you don't intend to mutate. Reaching for it "for performance" without profiling first is a trap. Proxies are fast enough for 99% of state. Profile, then decide.
Writing on:click out of muscle memory
Svelte 5 accepts on:click for backward compatibility in legacy mode, but mixing on:click and onclick in the same codebase is a mess. Pick one and lint for it. New Svelte 5 code is onclick.
Wrapping up
Runes are the first Svelte reactivity system that works the same way in every file. State, deriveds, and effects all share one mental model, and the boundary between components and plain JS dissolves.
If you are starting fresh, write everything in runes and forget stores exist for now. If you are migrating, do it one component at a time. Legacy mode is real. Either way, the mental model is simpler than the list of runes makes it look.
Next: Svelte snippets (which replaced <slot>). For the whole Svelte 5 + SvelteKit 2 stack end-to-end, there is the Full Stack SvelteKit course.
Happy coding!