Svelte 5 runes - the complete guide

Published
Updated
On this page

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:

  1. Does this produce a value my template or other code will read? Use $derived.

  2. Does it need to talk to the DOM, the network, or a library outside Svelte? Use $effect.

  3. Both? Use $derived for the value, $effect for 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:

  1. RxJS or async-stream interop. The $store auto-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.

  2. 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.

  3. Pub/sub with explicit subscription control. If you are building something that needs fine-grained lifecycle control over subscriptions, writable with .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 = 0let count = $state(0)

  • $: doubled = count * 2let doubled = $derived(count * 2)

  • $: { sideEffect(count) }$effect(() => sideEffect(count))

  • beforeUpdate(fn)$effect.pre(fn)

  • afterUpdate(fn)$effect(fn)

Props

  • export let foolet { foo } = $props()

  • export let foo = 'default'let { foo = 'default' } = $props()

  • $$propslet props = $props()

  • $$restPropslet { foo, ...rest } = $props()

  • export { x as class }let { class: x } = $props()

  • Any export let bindable → let { value = $bindable() } = $props()

Events

  • on:click={handler}onclick={handler}

  • on:click|preventDefault={handler} → call e.preventDefault() inside the handler

  • on:click|captureonclickcapture={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()} with let { 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 $state object passed as props

  • app.$on('event', fn) → callback prop

  • SSR: Component.render({ props })render(Component, { props }) from svelte/server

The surprises

A few changes are not obvious and bite during migration:

  • children is a reserved prop name, so you cannot have a prop called children that isn't snippet content.

  • <svelte:component this={X}> is no longer needed; components are dynamic by default.

  • The accessors and immutable component options are ignored in runes mode.

  • Class-field reactivity does not work by default. Assigning obj.foo = x in a plain class will not trigger updates. Declare class fields with $state explicitly.

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!