Svelte 5 Runes Deep Dive | Reactivity Rebuilt from Scratch

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

RunePurposeSvelte 4 equivalent
$state()Reactive variablelet x = value
$state.raw()Non-deep reactivelet x = value
$derived()Computed value$: x = expr
$derived.by()Complex computed$: { ... x = ... }
$effect()Side effect$: { sideEffect() }
$effect.pre()Before DOM updatebeforeUpdate()
$props()Component propsexport let prop
$bindable()Two-way propexport let prop + parent bind

Key Takeaways

  • Runes are explicit — no more magic implicit reactivity, easier to reason about
  • $state is deep reactive — object/array mutations are tracked; use $state.raw to opt out
  • $derived not $effect — for computed values, always prefer $derived over $effect setting state
  • $effect cleanup — return a function to clean up subscriptions, listeners, timers
  • Universal reactivity$state in .svelte.ts files 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