Svelte 5 Complete Guide | Runes, Snippets, $state, $derived & Migration
이 글의 핵심
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.
<!-- 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
.sveltefiles (in.js/.tsfor 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.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>
$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>
$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..." />
$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()} />
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} />
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>
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)} />
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>
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>
Migration: Svelte 4 → 5
# Automated migration tool
npx sv migrate svelte-5
Key changes:
| Svelte 4 | Svelte 5 |
|---|---|
let count = 0 | let count = $state(0) |
$: doubled = count * 2 | const doubled = $derived(count * 2) |
$: { sideEffect() } | $effect(() => { sideEffect(); }) |
export let prop | let { prop } = $props() |
<slot /> | {@render children()} |
<slot name="x" /> | {@render x()} |
on:click={handler} | onclick={handler} |
Svelte stores (writable) | .svelte.ts files with $state |
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}
Related posts: