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
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>
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>
$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>
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>
$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>
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>
$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} />
<!-- Parent -->
<script>
let name = $state('')
</script>
<TextInput bind:value={name} placeholder="Enter your name" />
<p>Hello, {name}</p>
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>
<Card title="User Profile">
<p>Name: Alice</p>
<p>Role: Admin</p>
{#snippet footer()}
<button>Edit Profile</button>
{/snippet}
</Card>
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>
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) }}>
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()
<!-- CartIcon.svelte -->
<script>
import { cart } from '$lib/cart.svelte'
</script>
<span>{cart.items.length} items — ${cart.total.toFixed(2)}</span>
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}`)
}
}
<form method="POST" use:enhance>
<input name="title" />
<textarea name="content"></textarea>
<button type="submit">Publish</button>
</form>
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
| 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)