Svelte 5完全ガイド — Runesで変わるリアクティビティ


Svelte 5完全ガイド — Runesで変わるリアクティビティ

Svelte 5は、フロントエンド開発の常識を覆す革新的なアップデートです。新しいRunes($state、$derived、$effect)により、リアクティビティの仕組みが根本的に変わり、より直感的で強力な開発体験を提供します。この記事では、Svelte 5の新機能から実践的な活用方法まで、2026年最新の情報とともに完全解説します。

Svelte 5とは

Svelte 5は、2024年後半にリリースされたSvelteの最新メジャーバージョンです。従来のSvelteは「消えるフレームワーク」として知られ、コンパイル時に最適化されたJavaScriptを生成することで、ランタイムの小ささと高速性を実現していました。

Svelte 5では、この哲学を維持しながら、リアクティビティシステムを刷新し、Runesという新しい概念を導入しました。

Svelte 5の主な変更点

Runesの導入 $state$derived$effectといったRunesにより、リアクティビティがより明示的で強力になりました。

シグナルベースのリアクティビティ 内部的にシグナル(Signals)パターンを採用し、細粒度の更新が可能になりました。

破壊的変更 従来のlet変数の自動リアクティビティは廃止され、明示的に$stateを使う必要があります。

パフォーマンス向上 より効率的な更新アルゴリズムにより、大規模アプリケーションでのパフォーマンスが大幅に改善されました。

Runes: Svelte 5の核心

Runesは、Svelte 5の最も重要な新機能です。$で始まるこれらの特殊な構文により、リアクティビティを明示的に制御できます。

$state - リアクティブな状態管理

$stateは、リアクティブな変数を宣言するためのRuneです。

基本的な使い方:

<script>
  let count = $state(0)

  function increment() {
    count++
  }
</script>

<button on:click={increment}>
  Count: {count}
</button>

従来のSvelteではlet count = 0で自動的にリアクティブでしたが、Svelte 5では明示的に$stateを使います。

オブジェクトと配列:

<script>
  let user = $state({
    name: 'Alice',
    age: 25
  })

  let todos = $state([
    { id: 1, text: 'Learn Svelte 5', done: false },
    { id: 2, text: 'Build an app', done: false }
  ])

  function updateUser() {
    user.age++ // リアクティブに更新される
  }

  function addTodo() {
    todos.push({ id: todos.length + 1, text: 'New todo', done: false })
  }
</script>

$stateで宣言されたオブジェクトや配列は、深くリアクティブです。ネストされたプロパティの変更も自動的に検知されます。

クラスとの組み合わせ:

<script>
  class Counter {
    count = $state(0)

    increment() {
      this.count++
    }

    reset() {
      this.count = 0
    }
  }

  let counter = new Counter()
</script>

<button on:click={() => counter.increment()}>
  Count: {counter.count}
</button>
<button on:click={() => counter.reset()}>Reset</button>

クラス内でも$stateを使用でき、オブジェクト指向的な設計が可能です。

$derived - 算出プロパティ

$derivedは、他の状態から導出される値を定義します。

基本的な使い方:

<script>
  let firstName = $state('John')
  let lastName = $state('Doe')
  let fullName = $derived(firstName + ' ' + lastName)
</script>

<input bind:value={firstName} />
<input bind:value={lastName} />
<p>Full name: {fullName}</p>

fullNamefirstNameまたはlastNameが変更されると自動的に再計算されます。

複雑な計算:

<script>
  let todos = $state([
    { text: 'Learn Svelte', done: true },
    { text: 'Build app', done: false },
    { text: 'Deploy', done: false }
  ])

  let completedCount = $derived(todos.filter(t => t.done).length)
  let totalCount = $derived(todos.length)
  let progress = $derived((completedCount / totalCount) * 100)
</script>

<p>Progress: {progress.toFixed(0)}%</p>
<p>{completedCount} of {totalCount} completed</p>

$derived.by - 複雑なロジック:

関数を使ったより複雑な導出には$derived.byを使います。

<script>
  let items = $state([1, 2, 3, 4, 5])

  let stats = $derived.by(() => {
    const sum = items.reduce((a, b) => a + b, 0)
    const avg = sum / items.length
    const max = Math.max(...items)
    const min = Math.min(...items)
    return { sum, avg, max, min }
  })
</script>

<p>Sum: {stats.sum}</p>
<p>Average: {stats.avg}</p>
<p>Max: {stats.max}, Min: {stats.min}</p>

$effect - 副作用の処理

$effectは、状態が変化したときに副作用(API呼び出し、ログ出力など)を実行します。

基本的な使い方:

<script>
  let count = $state(0)

  $effect(() => {
    console.log(`Count changed to ${count}`)
  })
</script>

countが変更されるたびに、effectが実行されます。

クリーンアップ:

<script>
  let isActive = $state(false)

  $effect(() => {
    if (!isActive) return

    const interval = setInterval(() => {
      console.log('Tick')
    }, 1000)

    // クリーンアップ関数を返す
    return () => {
      clearInterval(interval)
    }
  })
</script>

effectが再実行される前、またはコンポーネントが破棄される前に、クリーンアップ関数が呼ばれます。

ローカルストレージへの保存:

<script>
  let theme = $state(localStorage.getItem('theme') || 'light')

  $effect(() => {
    localStorage.setItem('theme', theme)
    document.documentElement.setAttribute('data-theme', theme)
  })
</script>

<select bind:value={theme}>
  <option value="light">Light</option>
  <option value="dark">Dark</option>
</select>

API呼び出し:

<script>
  let userId = $state(1)
  let user = $state(null)
  let loading = $state(false)

  $effect(async () => {
    loading = true
    try {
      const response = await fetch(`/api/users/${userId}`)
      user = await response.json()
    } catch (error) {
      console.error('Failed to fetch user', error)
    } finally {
      loading = false
    }
  })
</script>

{#if loading}
  <p>Loading...</p>
{:else if user}
  <p>User: {user.name}</p>
{/if}

$props - プロパティの受け取り

$propsは、親コンポーネントから受け取るプロパティを定義します。

<!-- Child.svelte -->
<script>
  let { title, count = 0 } = $props()
</script>

<h2>{title}</h2>
<p>Count: {count}</p>
<!-- Parent.svelte -->
<script>
  import Child from './Child.svelte'
</script>

<Child title="My Component" count={10} />

TypeScriptとの統合も強力です。

<script lang="ts">
  interface Props {
    title: string
    count?: number
  }

  let { title, count = 0 }: Props = $props()
</script>

SvelteKitとの統合

SvelteKitは、Svelteの公式フルスタックフレームワークです。Svelte 5との統合により、さらに強力になっています。

SvelteKitのセットアップ

npm create svelte@latest my-app
cd my-app
npm install
npm run dev

プロジェクト作成時に、Svelte 5を選択できます。

ファイルベースルーティング

SvelteKitは、ファイル構造がそのままルーティングになります。

src/routes/
├── +page.svelte           # /
├── about/
│   └── +page.svelte       # /about
├── blog/
│   ├── +page.svelte       # /blog
│   └── [slug]/
│       └── +page.svelte   # /blog/[slug]
└── api/
    └── users/
        └── +server.ts     # /api/users (APIエンドポイント)

ローダー関数

+page.tsまたは+page.server.tsでデータをロードします。

// src/routes/blog/+page.server.ts
export async function load() {
  const posts = await db.posts.findMany()
  return { posts }
}
<!-- src/routes/blog/+page.svelte -->
<script>
  let { data } = $props()
</script>

<h1>Blog Posts</h1>
{#each data.posts as post}
  <article>
    <h2>{post.title}</h2>
    <p>{post.excerpt}</p>
  </article>
{/each}

フォームアクション

フォームの送信をサーバーサイドで処理します。

// src/routes/login/+page.server.ts
import type { Actions } from './$types'

export const actions: Actions = {
  default: async ({ request }) => {
    const data = await request.formData()
    const email = data.get('email')
    const password = data.get('password')

    // 認証処理
    const user = await authenticate(email, password)

    if (!user) {
      return { success: false, message: 'Invalid credentials' }
    }

    return { success: true }
  }
}
<!-- src/routes/login/+page.svelte -->
<script>
  let { form } = $props()
</script>

<form method="POST">
  <input name="email" type="email" required />
  <input name="password" type="password" required />
  <button type="submit">Login</button>
</form>

{#if form?.success === false}
  <p class="error">{form.message}</p>
{/if}

レイアウト

共通のレイアウトを+layout.svelteで定義します。

<!-- src/routes/+layout.svelte -->
<script>
  import Header from '$lib/components/Header.svelte'
  import Footer from '$lib/components/Footer.svelte'

  let { children } = $props()
</script>

<div class="app">
  <Header />
  <main>
    {@render children()}
  </main>
  <Footer />
</div>

SSR、SSG、CSR

SvelteKitは柔軟なレンダリング戦略をサポートします。

// +page.ts
export const ssr = true // サーバーサイドレンダリング
export const prerender = true // 静的サイト生成
export const csr = false // クライアントサイドレンダリング無効化

React/Vueとの比較

コード量の比較

カウンターコンポーネント

React:

import { useState } from 'react'

export default function Counter() {
  const [count, setCount] = useState(0)

  return (
    <button onClick={() => setCount(count + 1)}>
      Count: {count}
    </button>
  )
}

Vue:

<script setup>
import { ref } from 'vue'

const count = ref(0)
</script>

<template>
  <button @click="count++">
    Count: {{ count }}
  </button>
</template>

Svelte 5:

<script>
  let count = $state(0)
</script>

<button on:click={() => count++}>
  Count: {count}
</button>

リアクティビティ

React useStateuseEffectなどのフックを使用。不変性を保つ必要がある。

const [user, setUser] = useState({ name: 'Alice', age: 25 })

// オブジェクトの更新
setUser({ ...user, age: 26 })

Vue refreactiveでリアクティブなデータを作成。

const user = reactive({ name: 'Alice', age: 25 })

// 直接変更可能
user.age = 26

Svelte 5 $stateで宣言し、直接変更可能。

let user = $state({ name: 'Alice', age: 25 })

// 直接変更可能
user.age = 26

バンドルサイズ

React

  • React + ReactDOM: ~40KB (gzip後)
  • 仮想DOMのオーバーヘッドあり

Vue

  • Vue 3: ~35KB (gzip後)
  • コンパイラ最適化あり

Svelte

  • ランタイムなし: ~2-5KB(アプリサイズによる)
  • コンパイル時に最適化

パフォーマンス

Svelteは仮想DOMを使わず、コンパイル時に最適化されたコードを生成するため、初期ロードとランタイムパフォーマンスに優れています。

js-framework-benchmark(2026年1月)の結果:

  • Svelte: 1.05x(バニラJSを1.00とした場合)
  • Vue: 1.18x
  • React: 1.52x

コンポーネント設計パターン

再利用可能なボタンコンポーネント

<!-- Button.svelte -->
<script>
  let {
    variant = 'primary',
    size = 'md',
    disabled = false,
    onclick,
    children
  } = $props()

  const variants = {
    primary: 'bg-blue-500 hover:bg-blue-600 text-white',
    secondary: 'bg-gray-200 hover:bg-gray-300 text-gray-800',
    danger: 'bg-red-500 hover:bg-red-600 text-white'
  }

  const sizes = {
    sm: 'px-2 py-1 text-sm',
    md: 'px-4 py-2',
    lg: 'px-6 py-3 text-lg'
  }

  const className = $derived(`${variants[variant]} ${sizes[size]} rounded disabled:opacity-50`)
</script>

<button class={className} {disabled} {onclick}>
  {@render children()}
</button>

使用例:

<script>
  import Button from '$lib/components/Button.svelte'
</script>

<Button variant="primary" size="lg" onclick={() => console.log('Clicked')}>
  Click me
</Button>

フォーム管理

<script>
  let formData = $state({
    email: '',
    password: '',
    remember: false
  })

  let errors = $state({})

  function validate() {
    errors = {}

    if (!formData.email.includes('@')) {
      errors.email = 'Invalid email'
    }

    if (formData.password.length < 8) {
      errors.password = 'Password must be at least 8 characters'
    }

    return Object.keys(errors).length === 0
  }

  async function handleSubmit() {
    if (!validate()) return

    const response = await fetch('/api/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(formData)
    })

    if (response.ok) {
      // ログイン成功
    }
  }
</script>

<form on:submit|preventDefault={handleSubmit}>
  <div>
    <label for="email">Email</label>
    <input id="email" type="email" bind:value={formData.email} />
    {#if errors.email}
      <p class="error">{errors.email}</p>
    {/if}
  </div>

  <div>
    <label for="password">Password</label>
    <input id="password" type="password" bind:value={formData.password} />
    {#if errors.password}
      <p class="error">{errors.password}</p>
    {/if}
  </div>

  <label>
    <input type="checkbox" bind:checked={formData.remember} />
    Remember me
  </label>

  <button type="submit">Login</button>
</form>

データテーブル

<script>
  let { data, columns } = $props()

  let sortColumn = $state(null)
  let sortDirection = $state('asc')
  let searchQuery = $state('')

  let filteredData = $derived.by(() => {
    if (!searchQuery) return data

    return data.filter(row =>
      columns.some(col =>
        String(row[col.key]).toLowerCase().includes(searchQuery.toLowerCase())
      )
    )
  })

  let sortedData = $derived.by(() => {
    if (!sortColumn) return filteredData

    return [...filteredData].sort((a, b) => {
      const aVal = a[sortColumn]
      const bVal = b[sortColumn]
      const multiplier = sortDirection === 'asc' ? 1 : -1

      if (aVal < bVal) return -1 * multiplier
      if (aVal > bVal) return 1 * multiplier
      return 0
    })
  })

  function handleSort(column) {
    if (sortColumn === column) {
      sortDirection = sortDirection === 'asc' ? 'desc' : 'asc'
    } else {
      sortColumn = column
      sortDirection = 'asc'
    }
  }
</script>

<input
  type="search"
  placeholder="Search..."
  bind:value={searchQuery}
/>

<table>
  <thead>
    <tr>
      {#each columns as col}
        <th on:click={() => handleSort(col.key)}>
          {col.label}
          {#if sortColumn === col.key}
            {sortDirection === 'asc' ? '↑' : '↓'}
          {/if}
        </th>
      {/each}
    </tr>
  </thead>
  <tbody>
    {#each sortedData as row}
      <tr>
        {#each columns as col}
          <td>{row[col.key]}</td>
        {/each}
      </tr>
    {/each}
  </tbody>
</table>

デプロイ

Vercel

npm install -g vercel
vercel

Netlify

npm install -g netlify-cli
netlify deploy

Cloudflare Pages

npm run build
npx wrangler pages publish .svelte-kit/cloudflare

静的サイトとして

# adapter-staticをインストール
npm install -D @sveltejs/adapter-static

# svelte.config.jsを編集
import adapter from '@sveltejs/adapter-static'

export default {
  kit: {
    adapter: adapter()
  }
}

# ビルド
npm run build

生成されたbuildディレクトリを任意のホスティングサービスにデプロイできます。

まとめ

Svelte 5は、Runesの導入により、リアクティビティシステムが大きく進化しました。$state$derived$effectといったシンプルで強力なプリミティブにより、複雑な状態管理も直感的に記述できます。

Svelte 5の強み

  • シンプルさ: ボイラープレートが少なく、学習コストが低い
  • パフォーマンス: 仮想DOMなしで高速
  • 小さいバンドル: ランタイムがないため、バンドルサイズが小さい
  • 優れたDX: TypeScript統合、優れたエラーメッセージ

Svelte 5を選ぶべきケース

  • 高速でバンドルサイズが小さいアプリが必要
  • シンプルな構文が好み
  • SvelteKitでフルスタック開発したい
  • 新規プロジェクト(移行コストがない)

React/Vueを選ぶべきケース

  • 既存の大規模なエコシステムが必要(React)
  • チームが既に習熟している
  • エンタープライズサポートが必要

Svelte 5とSvelteKitの組み合わせは、2026年現在、最もモダンで生産的なフロントエンド開発体験の一つです。ぜひ実際に試して、その素晴らしさを体感してください。