DEV Community

Carlos Oliva Pascual
Carlos Oliva Pascual

Posted on • Originally published at stacknotice.com

Svelte 5 Complete Guide: Runes, Snippets & SvelteKit (2026)

Svelte 5 is the biggest change to the framework since it launched. The compiler-driven approach stays — no virtual DOM, real DOM updates compiled away — but the reactivity model is completely new. Runes replace the implicit $: reactive statements with explicit, signal-based primitives that work consistently inside and outside components.

This matters because Svelte 4's reactivity had rough edges: reactive declarations only worked at the top level of components, stores were the only way to share state, and $: could be hard to reason about when dependencies were indirect. Runes fix all of this with a uniform API that works everywhere.

Installation

# New project (SvelteKit + Svelte 5)
npm create svelte@latest my-app
cd my-app
npm install
npm run dev
Enter fullscreen mode Exit fullscreen mode

The Runes System

Runes are compiler-recognized function calls prefixed with $. They look like functions but are processed at compile time — no imports needed, they're always in scope in .svelte files.

$state — Reactive Variables

<script>
  let count = $state(0)
  let user = $state({ name: 'Alice', score: 0 })

  function increment() {
    count++
  }

  function addScore(points: number) {
    user.score += points  // object mutations are tracked
  }
</script>

<button onclick={increment}>Count: {count}</button>
<p>{user.name}: {user.score} points</p>
Enter fullscreen mode Exit fullscreen mode

Deep reactivity works out of the box — mutating nested properties on a $state object triggers updates. No need to spread or replace the whole object.

$state.raw — Untracked State

When you have a large object you only replace as a whole (never mutate in place), use $state.raw to skip deep tracking:

<script>
  let items = $state.raw<string[]>([])

  function addItem(item: string) {
    items = [...items, item]  // replace, don't mutate
  }
</script>
Enter fullscreen mode Exit fullscreen mode

$derived — Computed Values

$derived replaces $: reactive declarations. It re-evaluates whenever its dependencies change:

<script>
  let price = $state(100)
  let quantity = $state(3)
  let discount = $state(0.1)

  const subtotal = $derived(price * quantity)
  const total = $derived(subtotal * (1 - discount))
  const formatted = $derived(`$${total.toFixed(2)}`)
</script>

<p>Subtotal: ${subtotal}</p>
<p>Total (10% off): {formatted}</p>
Enter fullscreen mode Exit fullscreen mode

For complex derived values that need multiple statements, use $derived.by:

<script>
  let items = $state([
    { name: 'Apple', qty: 3, price: 1.2 },
    { name: 'Bread', qty: 2, price: 2.5 },
  ])

  const summary = $derived.by(() => {
    const total = items.reduce((sum, item) => sum + item.qty * item.price, 0)
    const count = items.reduce((sum, item) => sum + item.qty, 0)
    return { total: total.toFixed(2), count }
  })
</script>

<p>{summary.count} items — ${summary.total}</p>
Enter fullscreen mode Exit fullscreen mode

$effect — Side Effects

$effect replaces $: statements that call functions or run side effects. It runs after the component mounts and re-runs when its reactive dependencies change:

<script>
  import { Chart } from 'chart.js'

  let data = $state([10, 25, 18, 42])
  let canvas: HTMLCanvasElement

  $effect(() => {
    const chart = new Chart(canvas, {
      type: 'bar',
      data: { labels: data.map((_, i) => `Day ${i + 1}`), datasets: [{ data }] }
    })

    return () => chart.destroy()  // cleanup runs before next effect or on unmount
  })
</script>

<canvas bind:this={canvas}></canvas>
Enter fullscreen mode Exit fullscreen mode

The return value of $effect is a cleanup function — the same pattern as React's useEffect. Don't mutate state inside $effect — it can create infinite loops.

$props — Component Props

In Svelte 5, props use $props() instead of export let:

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

  const { label, variant = 'primary', disabled = false, onclick }: Props = $props()
</script>

<button
  class="btn btn-{variant}"
  {disabled}
  {onclick}
>
  {label}
</button>
Enter fullscreen mode Exit fullscreen mode

$bindable — Two-Way Binding

When a prop should support bind:, mark it with $bindable:

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

<input bind:value {placeholder} />
Enter fullscreen mode Exit fullscreen mode
<!-- Parent -->
<script>
  let name = $state('')
</script>

<TextInput bind:value={name} placeholder="Enter your name" />
<p>Hello, {name}</p>
Enter fullscreen mode Exit fullscreen mode

Snippets — Replacing Slots

Svelte 5 replaces slots with Snippets. Snippets are reusable template fragments that can be passed as props or defined inline.

Basic Snippet

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

  interface Props {
    title: string
    children: Snippet
    footer?: Snippet
  }

  const { title, children, footer } = $props<Props>()
</script>

<div class="card">
  <h2>{title}</h2>
  <div class="card-body">
    {@render children()}
  </div>
  {#if footer}
    <div class="card-footer">
      {@render footer()}
    </div>
  {/if}
</div>
Enter fullscreen mode Exit fullscreen mode
<Card title="User Profile">
  <p>Name: Alice</p>
  <p>Role: Admin</p>

  {#snippet footer()}
    <button>Edit Profile</button>
  {/snippet}
</Card>
Enter fullscreen mode Exit fullscreen mode

Snippets with Parameters

Snippets accept arguments — the equivalent of scoped slots in Vue or render props in React:

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

  interface Props<T> {
    items: T[]
    row: Snippet<[T, number]>  // receives item and index
  }

  const { items, row } = $props<Props<unknown>>()
</script>

<table>
  <tbody>
    {#each items as item, i}
      <tr>{@render row(item, i)}</tr>
    {/each}
  </tbody>
</table>
Enter fullscreen mode Exit fullscreen mode

Event Handling Changes

Svelte 5 moves away from on:event directives to standard HTML event attributes:

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

<!-- Svelte 5 -->
<button onclick={handleClick}>Click</button>
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(e) }}>
Enter fullscreen mode Exit fullscreen mode

Shared State with Runes

State can live in .svelte.ts files and be shared across components — the replacement for Svelte stores:

// src/lib/cart.svelte.ts
function createCart() {
  let items = $state<CartItem[]>([])

  const total = $derived(
    items.reduce((sum, item) => sum + item.price * item.qty, 0)
  )

  function add(item: CartItem) {
    const existing = items.find((i) => i.id === item.id)
    if (existing) {
      existing.qty++
    } else {
      items.push(item)
    }
  }

  function remove(id: string) {
    items = items.filter((i) => i.id !== id)
  }

  return {
    get items() { return items },
    get total() { return total },
    add,
    remove,
  }
}

export const cart = createCart()
Enter fullscreen mode Exit fullscreen mode
<!-- CartIcon.svelte -->
<script>
  import { cart } from '$lib/cart.svelte'
</script>

<span>{cart.items.length} items — ${cart.total.toFixed(2)}</span>
Enter fullscreen mode Exit fullscreen mode

SvelteKit Form Actions

SvelteKit's form actions handle mutations server-side — no API routes needed for standard CRUD:

// src/routes/posts/new/+page.server.ts
import { fail, redirect } from '@sveltejs/kit'
import { z } from 'zod'
import type { Actions } from './$types'

const schema = z.object({
  title: z.string().min(3).max(200),
  content: z.string().min(10)
})

export const actions: Actions = {
  default: async ({ request, locals }) => {
    const formData = await request.formData()
    const parsed = schema.safeParse(Object.fromEntries(formData))

    if (!parsed.success) {
      return fail(400, { errors: parsed.error.flatten().fieldErrors })
    }

    const post = await locals.db.post.create({ data: parsed.data })
    redirect(303, `/blog/${post.slug}`)
  }
}
Enter fullscreen mode Exit fullscreen mode
<form method="POST" use:enhance>
  <input name="title" />
  <textarea name="content"></textarea>
  <button type="submit">Publish</button>
</form>
Enter fullscreen mode Exit fullscreen mode

use:enhance progressively enhances the form — works without JS (plain HTML form), with JS submits via fetch without a full page reload.

Migrating from Svelte 4

npx sv migrate svelte-5
Enter fullscreen mode Exit fullscreen mode
Svelte 4 Svelte 5
export let count = 0 const { count = 0 } = $props()
$: double = count * 2 const double = $derived(count * 2)
$: console.log(count) $effect(() => { console.log(count) })
<slot /> {@render children()}
on:click={handler} onclick={handler}
writable(0) from svelte/store $state(0) in .svelte.ts

Stores still work in Svelte 5 — no need to migrate them immediately.

Svelte 5 vs React and Vue 3

Svelte 5 React Vue 3
Reactivity model Runes (compile-time) useState/hooks Composition API
Bundle size (runtime) ~8KB ~45KB ~22KB
No virtual DOM Yes No No
SSR framework SvelteKit Next.js Nuxt
Learning curve Low Medium Medium

Svelte's compile-time approach means less JavaScript shipped to the browser. For content-heavy sites and apps where bundle size matters, Svelte wins clearly. For apps that lean heavily on the npm ecosystem (data tables, charts, editors), React's depth still has an edge.


Runes make Svelte's reactivity predictable and portable. State that used to only work inside components now works in plain TypeScript files — shared state without stores, reactive utilities without component lifecycle constraints. Combined with SvelteKit's server-first data loading and form actions, Svelte 5 is a compelling choice for full-stack TypeScript projects where performance and bundle size are priorities.

Full article at stacknotice.com/blog/svelte-5-complete-guide-2026

Top comments (0)