Svelte 5 Complete Guide | Runes, Snippets, $state, $derived & Migration

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 .svelte files (in .js/.ts for 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 4Svelte 5
let count = 0let count = $state(0)
$: doubled = count * 2const doubled = $derived(count * 2)
$: { sideEffect() }$effect(() => { sideEffect(); })
export let proplet { 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: