Svelte 5 Runes: $state, $derived, $effect, Snippets and Migrating from Svelte 4

Key takeaways

Svelte 5 replaces the compiler-magic reactivity of Svelte 4 with explicit Runes — $state, $derived, $effect, $props. The result is more predictable reactivity, better TypeScript support, and a simpler mental model for complex state.

What Changed in Svelte 5

Svelte 5 replaces implicit compiler magic with explicit Runes — special function calls the compiler recognizes.

The change is bigger than syntax. In Svelte 4, let count = 0 was reactive purely because the compiler noticed a top-level let in a .svelte file and rewrote it behind the scenes — which meant reactivity only worked inside .svelte files, and the same code moved into a plain .js file for sharing between components silently stopped being reactive, with no error to warn you. Runes fix that by making reactivity an explicit function call ($state(0)) rather than an implicit property of where the code lives — $state works identically whether it’s in a .svelte file or a .svelte.ts file, which is what finally makes shareable reactive logic (the pattern shown later in this guide) a first-class feature instead of a workaround involving Svelte’s store API.

<!-- Svelte 4 -->
<script>
  let count = 0;           // Reactive by default (compiler magic)
  $: doubled = count * 2;  // Reactive declaration ($ label)
</script>

<!-- Svelte 5 -->
<script>
  let count = $state(0);              // Explicit reactive state
  const doubled = $derived(count * 2); // Explicit derived value
</script>

Why Runes?

  • Work outside .svelte files (in .js/.ts for shared logic)
  • Explicit — no “which variables are reactive?” confusion
  • Better TypeScript inference
  • Consistent with how frameworks like Solid and Angular handle reactivity

$state — Reactive State

<script lang="ts">
  // Primitive state
  let count = $state(0);
  let name = $state('Alice');
  let visible = $state(true);

  // Object state — deep reactive (nested properties are also reactive)
  let user = $state({
    name: 'Alice',
    settings: { theme: 'dark', lang: 'en' }
  });

  function updateTheme() {
    user.settings.theme = 'light'; // ✅ Reactive — UI updates automatically
  }

  // Array state
  let items = $state<string[]>([]);

  function addItem(item: string) {
    items.push(item);  // ✅ Reactive push
  }
</script>

<button onclick={() => count++}>Count: {count}</button>
<p>Doubled: {count * 2}</p>

$state wrapping an object doesn’t just make the top-level variable reactive — it recursively proxies every nested object and array, which is why mutating user.settings.theme directly (rather than reassigning the whole user object) still triggers a UI update. That’s a deliberate departure from React’s model, where mutating nested state directly is a bug (React can’t see the mutation and won’t re-render) and the idiomatic fix is always to create a new object. Svelte 5’s deep reactivity means the code that “looks like it should just work” — mutating a property, pushing to an array — actually does, at the cost of some overhead from the Proxy wrapping every nested value. $state.raw, covered next, is the escape hatch for when that overhead or that mutation-tracking behavior isn’t what you want.

$state.raw — Shallow Reactive

<script>
  // Only the reference is reactive, not the contents
  let items = $state.raw(['a', 'b', 'c']);

  function replace() {
    items = [...items, 'd']; // Must reassign to trigger update
  }
</script>

$state.raw trades deep reactivity for performance and simpler semantics — it skips the Proxy wrapping entirely, so mutating a property or pushing to an array does nothing visible, silently, which is exactly the trap to watch for when reaching for it. The trade is worth it for large, deeply-nested data you always replace wholesale rather than mutate in place (a full API response you re-fetch and swap in, for instance), where deep-proxying every nested field on every update would be pure overhead with no benefit, since nothing ever mutates a single field in isolation anyway.


$derived — Computed Values

<script lang="ts">
  let price = $state(100);
  let quantity = $state(3);
  let taxRate = $state(0.1);

  // Recomputes automatically when dependencies change
  const subtotal = $derived(price * quantity);
  const tax = $derived(subtotal * taxRate);
  const total = $derived(subtotal + tax);

  // Complex derived with $derived.by
  const sortedItems = $derived.by(() => {
    // Use .by for multi-line derived logic
    return items
      .filter(item => item.active)
      .sort((a, b) => a.name.localeCompare(b.name));
  });
</script>

<p>Subtotal: ${subtotal.toFixed(2)}</p>
<p>Tax: ${tax.toFixed(2)}</p>
<p>Total: ${total.toFixed(2)}</p>

The dependency chain here — tax depends on subtotal, total depends on both — is tracked automatically by reading which reactive values each $derived expression actually touches during evaluation, not by a manually specified dependency array the way React’s useMemo requires. Change taxRate, and Svelte recomputes tax and total but leaves subtotal untouched, because subtotal never read taxRate. This is the single biggest usability difference from React’s memoization hooks: there’s no dependency array to get wrong, no eslint-plugin warning you forgot a dependency, no stale-closure bugs from a dependency array that’s out of sync with what the function body actually reads — the compiler’s tracking is exact because it’s based on what the code does, not on a list a human maintains by hand.


$effect — Side Effects

<script lang="ts">
  let query = $state('');
  let results = $state<string[]>([]);

  // Runs after DOM updates, re-runs when dependencies change
  $effect(() => {
    if (query.length < 2) {
      results = [];
      return;
    }

    // Fetch results when query changes
    fetchResults(query).then(data => results = data);

    // Return cleanup function
    return () => {
      // Cancels previous fetch if query changes before it completes
    };
  });

  // $effect.pre — runs before DOM updates (like useLayoutEffect)
  $effect.pre(() => {
    // Measure DOM before render
  });
</script>

<input bind:value={query} placeholder="Search..." />

The cleanup function returned from $effect runs before the next execution of the same effect, not just on component unmount — which is the exact same contract React’s useEffect cleanup has, and it exists for the exact same reason: without it, a fast-typing user triggering fetchResults on every keystroke would have every previous, now-stale fetch’s .then() still resolve and potentially overwrite results with an outdated response arriving after a newer one. The comment in this example undersells what needs to actually happen — “cancels previous fetch” should mean aborting an in-flight AbortController-backed request, not just a comment; without a real cancellation mechanism, the cleanup function alone doesn’t stop a fetch already in flight, it only stops you from acting on it if you check a “is this still the current query” flag inside the .then().


$props — Component Props

<!-- Button.svelte -->
<script lang="ts">
  interface Props {
    label: string;
    variant?: 'primary' | 'secondary';
    disabled?: boolean;
    onclick?: () => void;
  }

  // $props() destructures all props at once
  let { label, variant = 'primary', disabled = false, onclick }: Props = $props();
</script>

<button
  class={`btn btn-${variant}`}
  {disabled}
  {onclick}
>
  {label}
</button>
<!-- Usage -->
<Button label="Submit" variant="primary" onclick={() => handleSubmit()} />

$props() replacing individual export let declarations is a smaller change than Runes elsewhere in this guide, but it fixes a real awkwardness: in Svelte 4, each prop needed its own export let name line, which meant destructuring-with-defaults (variant = 'primary') worked, but there was no single place that showed you the whole prop shape at a glance in components with many props — you had to scan every export let line. Destructuring $props() against a Props interface, as shown here, gives you both the runtime defaults and a single TypeScript-checked shape in one declaration, which is a meaningfully easier component to skim when it has eight or ten props instead of two or three.

Spread Props

<script lang="ts">
  let { class: className, ...rest } = $props();
</script>

<!-- Pass remaining props to underlying element -->
<button class={`base-btn ${className}`} {...rest} />

Bindable Props

<!-- Input.svelte -->
<script lang="ts">
  let { value = $bindable('') } = $props();
</script>

<input bind:value />
<!-- Usage — two-way binding -->
<Input bind:value={myValue} />

$bindable() is the piece that makes two-way binding opt-in and visible rather than implicit — a plain prop declared with $props() is one-way by default (parent to child only), and a child mutating it would just be a local, disconnected change that never propagates back up. Wrapping the default value in $bindable() explicitly marks that specific prop as allowed to flow back to the parent via bind:value, which means reading a component’s Props interface tells you exactly which props participate in two-way binding without having to check every call site — a real improvement over Svelte 4, where nothing in a component’s own source distinguished a bindable prop from a regular one.


Snippets — Replace Slots

Snippets replace the <slot> system with explicit, composable render functions.

<!-- Card.svelte -->
<script lang="ts">
  import type { Snippet } from 'svelte';

  interface Props {
    title: string;
    children: Snippet;             // Default snippet (replaces default slot)
    footer?: Snippet;              // Optional named snippet
    header?: Snippet<[string]>;    // Snippet with parameters
  }

  let { title, children, footer, header } = $props();
</script>

<div class="card">
  <div class="card-header">
    {#if header}
      {@render header(title)}
    {:else}
      <h2>{title}</h2>
    {/if}
  </div>

  <div class="card-body">
    {@render children()}
  </div>

  {#if footer}
    <div class="card-footer">
      {@render footer()}
    </div>
  {/if}
</div>
<!-- Usage -->
<Card title="My Card">
  <!-- Default children snippet -->
  <p>Card content goes here.</p>

  {#snippet footer()}
    <button>Close</button>
  {/snippet}

  {#snippet header(title)}
    <h2 class="custom-title">{title}</h2>
    <span class="badge">New</span>
  {/snippet}
</Card>

The upgrade from slots to snippets is really about parameters. Svelte 4 slots could pass data down via let: directives, but the mechanism was easy to get wrong and hard to type — the compiler couldn’t fully verify that a slot’s expected let: bindings matched what the component actually passed through <slot {data}>. Snippet<[string]> in the Props interface above makes that contract an explicit, checkable TypeScript type: header is a function that takes a string and renders something, full stop, and {@render header(title)} is just calling it — same mental model as calling any other function, rather than the more magical slot-binding syntax it replaces.


Event Handling

Svelte 5 uses standard DOM event attributes (no more on: directive):

<!-- Svelte 4 -->
<button on:click={handleClick}>Click</button>
<button on:click|preventDefault={handleSubmit}>Submit</button>

<!-- Svelte 5 -->
<button onclick={handleClick}>Click</button>

<!-- Modifiers via inline handler -->
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }}>
  <button type="submit">Submit</button>
</form>

<!-- Inline handlers -->
<button onclick={() => count++}>+1</button>
<input oninput={(e) => (value = e.currentTarget.value)} />

Dropping the on: directive in favor of plain DOM event attributes isn’t just a syntax simplification — onclick={handler} is now literally the same property every browser’s DOM API already exposes, which means Svelte components interop more naturally with vanilla JS patterns and TypeScript’s built-in DOM typings, rather than needing Svelte-specific type definitions for its own custom directive syntax. The cost is that event modifiers like Svelte 4’s on:click|preventDefault no longer exist as a shorthand; you write e.preventDefault() explicitly inside the handler, which is one extra line but removes a small piece of Svelte-specific syntax you had to memorize.


Shared Reactive State (Runes in .svelte.ts)

One of the biggest Svelte 5 improvements — reactive logic in plain .svelte.ts files:

// stores/counter.svelte.ts
// This is a .svelte.ts file — runes work here!

function createCounter(initial = 0) {
  let count = $state(initial);
  const doubled = $derived(count * 2);

  return {
    get count() { return count; },
    get doubled() { return doubled; },
    increment() { count++; },
    reset() { count = initial; },
  };
}

export const counter = createCounter(0);
<!-- Any component can import and use it -->
<script>
  import { counter } from '../stores/counter.svelte.ts';
</script>

<button onclick={counter.increment}>
  Count: {counter.count} (doubled: {counter.doubled})
</button>

The .svelte.ts extension isn’t decorative — it’s what tells the Svelte compiler to process this file at all, since Runes are compiler intrinsics, not runtime functions imported from the svelte package. A plain .ts file containing $state(0) would just be a syntax error (or, worse, silently call some unrelated $state if one happened to exist), because nothing would compile it. The get count()/get doubled() accessor pattern here matters too: exporting count as a plain value would export a frozen snapshot of the number at import time, not a live reference — getters are what let every importer read the current reactive value on each access, which is the entire point of sharing reactive state across components in the first place.


Lifecycle

<script>
  import { onMount, onDestroy } from 'svelte';

  let element: HTMLElement;

  onMount(() => {
    // Runs after component mounts to DOM
    const observer = new ResizeObserver(() => { /* ... */ });
    observer.observe(element);

    return () => observer.disconnect();  // Cleanup on destroy
  });

  // $effect replaces most onMount use cases in Svelte 5
  $effect(() => {
    const timer = setInterval(() => tick(), 1000);
    return () => clearInterval(timer);
  });
</script>

The comment “$effect replaces most onMount use cases” is worth taking literally rather than skimming past: onMount still exists and still runs exactly once after the component first renders, but it has no way to react to reactive state changing afterward — historically people reached for a combination of onMount plus a reactive $: statement to get “run once, then re-run on change” behavior. $effect collapses that into one construct that runs on mount and automatically re-runs whenever any reactive value it reads changes, which is why the setInterval example above doesn’t need a separate onMount wrapper at all. Keep onMount for things that are genuinely one-time and don’t depend on reactive state — a resize observer setup, an analytics “page viewed” ping — and reach for $effect for anything that should track changing values.


Migration: Svelte 4 — 5

# Automated migration tool
npx sv migrate svelte-5

Key changes:

Svelte 4Svelte 5
let count = 0let count = $state(0)
$: doubled = count * 2const doubled = $derived(count * 2)
$: { sideEffect() }$effect(() => { sideEffect(); })
export let proplet { prop } = $props()
<slot />{@render children()}
<slot name="x" />{@render x()}
on:click={handler}onclick={handler}
Svelte stores (writable).svelte.ts files with $state

The migration tool handles the mechanical syntax rewrites in this table reliably — let to $state, $: to $derived/$effect — but it can’t always tell which of $derived or $effect a given $: statement should become, since that depends on whether the statement computes a value or performs a side effect, a distinction Svelte 4’s single $: label blurred together. Expect to review every automated conversion by hand, especially any $: block that both computes something and has a side effect (like $: { total = a + b; console.log(total); }) — those need to be split into a $derived for the value and a separate $effect for the logging, which the migration tool can’t safely infer on its own.


SvelteKit Integration

Svelte 5 works seamlessly with SvelteKit — the routing and SSR layer:

// src/routes/posts/+page.server.ts
import type { PageServerLoad } from './$types';

export const load: PageServerLoad = async ({ fetch }) => {
  const posts = await fetch('/api/posts').then(r => r.json());
  return { posts };
};
<!-- src/routes/posts/+page.svelte -->
<script lang="ts">
  import type { PageData } from './$types';

  let { data }: { data: PageData } = $props();
  // data.posts is fully typed from the load function
</script>

{#each data.posts as post}
  <article>
    <h2>{post.title}</h2>
  </article>
{/each}