Svelte 5 Runes Deep Dive | Reactivity Rebuilt from Scratch
이 글의 핵심
Svelte 5 rewrites reactivity with runes — explicit signals that replace implicit reactive declarations. The result is more predictable, more composable, and works outside .svelte files. This guide covers every rune with real patterns.
Why Runes?
Svelte 4’s reactivity had limitations:
<!-- Svelte 4 — works in .svelte files only -->
<script>
let count = 0 // reactive variable (implicit)
$: doubled = count * 2 // reactive statement (implicit)
$: if (count > 10) console.log('high') // reactive side effect
// Problem: doesn't work if count is reassigned inside a callback
function reset() {
// count = 0 — reactive
// But setTimeout(() => count = 0) — NOT reactive in some cases
}
</script>
Svelte 5 runes are explicit signals — predictable everywhere:
<!-- Svelte 5 — explicit, works everywhere -->
<script>
let count = $state(0)
let doubled = $derived(count * 2)
$effect(() => { if (count > 10) console.log('high') })
</script>
1. $state — Reactive State
<script>
// Primitive state
let count = $state(0)
let name = $state('Alice')
let isOpen = $state(false)
// Object state — deep reactive (every property is reactive)
let user = $state({
name: 'Alice',
age: 30,
preferences: { theme: 'dark' },
})
// Array state — methods like push/pop are reactive
let todos = $state([
{ id: 1, text: 'Learn runes', done: false },
])
function addTodo(text) {
todos.push({ id: Date.now(), text, done: false }) // reactive!
}
function toggleTodo(id) {
const todo = todos.find(t => t.id === id)
if (todo) todo.done = !todo.done // reactive mutation!
}
// Update nested property
function updateTheme(theme) {
user.preferences.theme = theme // reactive!
}
</script>
<button onclick={() => count++}>{count}</button>
<p>Hello, {user.name} (age {user.age})</p>
{#each todos as todo}
<label>
<input type="checkbox" checked={todo.done} onchange={() => toggleTodo(todo.id)} />
{todo.text}
</label>
{/each}
$state.raw — non-deep reactive
<script>
// Only the assignment is reactive, not mutations
// Useful for large objects you replace wholesale
let items = $state.raw([1, 2, 3, 4, 5])
function shuffle() {
items = [...items].sort(() => Math.random() - 0.5) // reactive (reassignment)
// items.push(6) // NOT reactive — raw state doesn't track mutations
}
</script>
2. $derived — Computed Values
<script>
let items = $state([
{ name: 'Apple', price: 1.5, category: 'fruit' },
{ name: 'Bread', price: 2.0, category: 'bakery' },
{ name: 'Banana', price: 0.75, category: 'fruit' },
])
let filter = $state('all')
let sortBy = $state('name')
// Simple derived
let total = $derived(items.reduce((sum, item) => sum + item.price, 0))
// Derived from derived
let filtered = $derived(
filter === 'all' ? items : items.filter(i => i.category === filter)
)
let sorted = $derived(
[...filtered].sort((a, b) => a[sortBy] < b[sortBy] ? -1 : 1)
)
let itemCount = $derived(filtered.length)
// $derived.by for complex logic
let stats = $derived.by(() => {
const prices = items.map(i => i.price)
return {
min: Math.min(...prices),
max: Math.max(...prices),
avg: prices.reduce((s, p) => s + p, 0) / prices.length,
count: items.length,
}
})
</script>
<p>Showing {itemCount} items, total: ${total.toFixed(2)}</p>
<p>Price range: ${stats.min} - ${stats.max}, avg: ${stats.avg.toFixed(2)}</p>
{#each sorted as item}
<div>{item.name} - ${item.price}</div>
{/each}
3. $effect — Side Effects
<script>
let query = $state('')
let results = $state([])
let loading = $state(false)
// Runs when dependencies change, cleans up before re-run
$effect(() => {
if (!query) {
results = []
return
}
loading = true
const controller = new AbortController()
fetch(`/api/search?q=${query}`, { signal: controller.signal })
.then(r => r.json())
.then(data => { results = data; loading = false })
.catch(err => { if (err.name !== 'AbortError') loading = false })
// Cleanup: runs before next effect and on destroy
return () => {
controller.abort()
}
})
// Effect that only runs once (on mount)
$effect(() => {
const handler = (e) => console.log('key:', e.key)
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
})
// $effect.pre — runs before DOM update (like useLayoutEffect)
$effect.pre(() => {
console.log('before DOM update, count is:', count)
})
</script>
Don’t overuse $effect
<script>
let a = $state(1)
let b = $state(2)
// ❌ Don't use $effect for derived values
let sum = $state(0)
$effect(() => { sum = a + b }) // wrong pattern
// ✅ Use $derived instead
let sum2 = $derived(a + b)
</script>
4. $props — Component Props
<!-- Button.svelte -->
<script>
// Destructure props with defaults
let {
children,
variant = 'primary',
size = 'md',
disabled = false,
onclick,
class: className = '', // rename reserved word
...rest // spread remaining props
} = $props()
</script>
<button
class="btn btn-{variant} btn-{size} {className}"
{disabled}
{...rest}
{onclick}
>
{@render children()}
</button>
<!-- Usage -->
<Button variant="secondary" size="lg" onclick={() => console.log('clicked')}>
Click Me
</Button>
5. $bindable — Two-Way Binding Props
<!-- Input.svelte -->
<script>
let {
value = $bindable(''), // bindable — parent can use bind:value
label = '',
type = 'text',
} = $props()
</script>
<label>
{label}
<input {type} bind:value />
</label>
<!-- Parent.svelte -->
<script>
import Input from './Input.svelte'
let name = $state('')
</script>
<!-- Two-way binding works because value is $bindable -->
<Input label="Name" bind:value={name} />
<p>Hello, {name}!</p>
6. Snippets — Reusable Template Fragments
Snippets replace slot patterns with a more composable approach:
<!-- Card.svelte -->
<script>
let { header, children, footer } = $props()
</script>
<div class="card">
<div class="card-header">
{@render header?.()}
</div>
<div class="card-body">
{@render children()}
</div>
{#if footer}
<div class="card-footer">
{@render footer()}
</div>
{/if}
</div>
<!-- Usage -->
<Card>
{#snippet header()}
<h2>My Card</h2>
{/snippet}
<p>Card content goes here.</p>
{#snippet footer()}
<button>Action</button>
{/snippet}
</Card>
<!-- Inline snippet (reusable template) -->
{#snippet userRow(user)}
<tr>
<td>{user.name}</td>
<td>{user.email}</td>
<td>{user.role}</td>
</tr>
{/snippet}
<table>
{#each users as user}
{@render userRow(user)}
{/each}
</table>
7. Universal Reactivity — $state in .ts files
The biggest Svelte 5 feature: reactivity outside .svelte files.
// store/counter.svelte.ts
export class Counter {
count = $state(0)
doubled = $derived(this.count * 2)
increment() { this.count++ }
decrement() { this.count-- }
reset() { this.count = 0 }
}
export const counter = new Counter()
<!-- App.svelte -->
<script>
import { counter } from './store/counter.svelte.ts'
</script>
<button onclick={() => counter.increment()}>+</button>
<p>{counter.count} (×2 = {counter.doubled})</p>
<button onclick={() => counter.decrement()}>-</button>
Shared state pattern
// store/auth.svelte.ts
function createAuthStore() {
let user = $state<User | null>(null)
let loading = $state(false)
let isAuthenticated = $derived(user !== null)
async function login(email: string, password: string) {
loading = true
try {
user = await authApi.login(email, password)
} finally {
loading = false
}
}
function logout() {
user = null
}
return {
get user() { return user },
get loading() { return loading },
get isAuthenticated() { return isAuthenticated },
login,
logout,
}
}
export const auth = createAuthStore()
<script>
import { auth } from '$lib/store/auth.svelte.ts'
</script>
{#if auth.isAuthenticated}
<p>Welcome, {auth.user?.name}!</p>
<button onclick={() => auth.logout()}>Logout</button>
{:else}
<button onclick={() => auth.login('[email protected]', 'password')}>
Login
</button>
{/if}
8. Migrating from Svelte 4
# Automated migration
npx sv migrate svelte-5
<!-- Svelte 4 → Svelte 5 -->
<!-- State -->
let count = 0 → let count = $state(0)
<!-- Reactive statements -->
$: doubled = count * 2 → let doubled = $derived(count * 2)
$: { doSomething() } → $effect(() => { doSomething() })
<!-- Props -->
export let value → let { value } = $props()
export let value = 'x' → let { value = 'x' } = $props()
<!-- Events (Svelte 5 uses DOM events) -->
on:click={handler} → onclick={handler}
on:submit|preventDefault={handler} → onsubmit={(e) => { e.preventDefault(); handler(e) }}
<!-- Stores (still work, but can use $state instead) -->
import { writable } from 'svelte/store'
const count = writable(0)
$count → use $state in .svelte.ts files instead
Runes Summary
| Rune | Purpose | Svelte 4 equivalent |
|---|---|---|
$state() | Reactive variable | let x = value |
$state.raw() | Non-deep reactive | let x = value |
$derived() | Computed value | $: x = expr |
$derived.by() | Complex computed | $: { ... x = ... } |
$effect() | Side effect | $: { sideEffect() } |
$effect.pre() | Before DOM update | beforeUpdate() |
$props() | Component props | export let prop |
$bindable() | Two-way prop | export let prop + parent bind |
Key Takeaways
- Runes are explicit — no more magic implicit reactivity, easier to reason about
$stateis deep reactive — object/array mutations are tracked; use$state.rawto opt out$derivednot$effect— for computed values, always prefer$derivedover$effectsetting state$effectcleanup — return a function to clean up subscriptions, listeners, timers- Universal reactivity —
$statein.svelte.tsfiles replaces stores with a cleaner API - Snippets — replace named slots with composable, parameterized template fragments
- Gradual migration — Svelte 4 components work in Svelte 5; migrate incrementally