Svelte 5 は「コンパイラの魔法でリアクティブになる」Svelte 4 までの仕組みを捨て、Runes という明示的・ランタイム・ユニバーサルなリアクティビティに生まれ変わりました。2024 年 10 月の 5.0 安定版リリース以降、$state / $derived / $effect / $props / $bindable / $inspect / $host の 7 つの Runes と、Snippets による柔軟なコンテンツ再利用が Svelte の新しい基盤になりました。
let 変数が勝手にリアクティブになる「黒魔術」は、わかりやすい反面「どこまでがリアクティブか」「コンポーネント外でも動くか」という疑問に曖昧さを残していました。Runes は「リアクティブにしたい値は $state で明示する・コンポーネント外でも同じ仕組みで動く」という原則でこれを解消します。この記事では 2026 年 4 月時点の Svelte 5.x を前提に、全 Runes の使い方、Snippets、Universal Reactivity、Svelte 4 からの移行、SvelteKit との連携、TypeScript 型付け、落とし穴までを体系的に解説します。
- Svelte 4 と Svelte 5 のパラダイム比較
- $state ── リアクティブ状態の宣言
- $derived ── 派生値の計算
- $effect ── 副作用の実行
- $props ── プロパティの受け取り
- $bindable ── 親へ値を書き戻す
- $inspect ── リアクティブなデバッグ出力
- $host ── カスタム要素内でのホスト要素参照
- Snippets と {@render} ── スロットの完全置換
- イベントハンドラとコールバック Props
- Universal Reactivity ── .svelte.js / .svelte.ts の威力
- TypeScript での型付けパターン
- Svelte 4 → 5 移行手順
- SvelteKit と Svelte 5
- 落とし穴と注意点
- よくある質問
- まとめ
Svelte 4 と Svelte 5 のパラダイム比較
まずは全体像を把握するために、Svelte 4 の書き方が Svelte 5 でどう変わるかを一覧で見ておきます。
| 目的 | Svelte 4 | Svelte 5 |
|---|---|---|
| リアクティブ変数 | let count = 0; |
let count = $state(0); |
| 派生値 | $: double = count * 2; |
let double = $derived(count * 2); |
| 副作用 | $: { console.log(count); } |
$effect(() => console.log(count)); |
| プロパティ | export let name; |
let { name } = $props(); |
| 双方向バインド | export let value; |
let { value = $bindable() } = $props(); |
| イベント | on:click={fn} |
onclick={fn} |
| 子要素 | <slot /> |
{@render children()} |
| 名前付きスロット | <slot name="header"> |
Snippets を props で受け取る |
| イベント発行 | createEventDispatcher() |
コールバック props |
| コンポーネント外の状態 | Stores(writable) |
.svelte.js で $state 直接 |
let をリアクティブに書き換える」仕組みで、.svelte ファイル内でしか魔法が効きませんでした。Svelte 5 の Runes はランタイムのシグナルベースで動くため、.svelte / .svelte.js / .svelte.ts を問わず同じ書き方でリアクティブになります(= Universal Reactivity)。これにより Store という別概念を覚える必要が消えました。$state ── リアクティブ状態の宣言
$state() で包んだ値は、参照するあらゆる場所(テンプレート・派生値・副作用)に変更が自動伝搬します。プリミティブも配列もオブジェクトもそのまま書けます。
<script>
let count = $state(0);
function increment() {
count += 1; // 代入でそのまま更新できる(Store の .set() 不要)
}
</script>
<button onclick={increment}>
clicked {count} {count === 1 ? "time" : "times"}
</button>
オブジェクト・配列は「深くリアクティブ」
<script>
let todos = $state([
{ id: 1, text: "Svelte 5 を学ぶ", done: false },
]);
function add() {
todos.push({ id: Date.now(), text: "", done: false }); // OK
}
function toggle(i) {
todos[i].done = !todos[i].done; // OK
}
</script>
{#each todos as todo, i (todo.id)}
<label>
<input type="checkbox" checked={todo.done} onchange={() => toggle(i)} />
{todo.text}
</label>
{/each}
<button onclick={add}>追加</button>
$state(obj) はオブジェクトを Proxy でラップし、ネストした代入まで検出します。ただし Proxy なので obj === $state(obj) は falseです。DOM API や外部ライブラリに渡す時は $state.snapshot(obj) で素の値に戻せます。.svelte.js / .svelte.ts でも同じ書き方
// .svelte.ts 拡張子なら runes が使える
export function createCounter(initial = 0) {
let count = $state(initial);
return {
get count() { return count; },
increment() { count += 1; },
reset() { count = 0; },
};
}
<script>
import { createCounter } from "./counter.svelte.ts";
const counter = createCounter(10);
</script>
<button onclick={counter.increment}>
{counter.count}
</button>
$state 変数は再代入で更新されるため、外部に公開するときは get count() { return count; } の形にするのが定石です。直接 return { count } と書くと、オブジェクト生成時点の値が固定されてしまいます。$derived ── 派生値の計算
$derived() は依存先の値が変わると自動再計算される派生値です。Svelte 4 の $: ラベルの置換ですが、関数として切り出しても依存追跡が効くのが大きな違いです。
<script>
let width = $state(10);
let height = $state(5);
// ① インライン
let area = $derived(width * height);
// ② 複雑なロジックは $derived.by で関数形式
let category = $derived.by(() => {
if (area < 20) return "small";
if (area < 100) return "medium";
return "large";
});
</script>
<input type="number" bind:value={width} />
<input type="number" bind:value={height} />
<p>面積: {area}({category})</p>
$: はコンパイル時に依存を解析していたため、関数に切り出すと追跡が切れる制限がありました。$derived は実行時に参照された $state を追跡するため、任意の関数呼び出しを経由しても正しく依存が捕捉されます。このおかげで「ロジックを関数に切り出す」リファクタリングが安全に行えます。$effect ── 副作用の実行
$effect() はリアクティブ値の変更に応じて副作用を走らせます。DOM 直接操作・外部 API 通知・ログ出力・サードパーティライブラリとの同期などに使います。コンポーネントの mount 時と依存変更時に実行され、戻り値の関数が unmount 時に呼ばれるのが React の useEffect と同じ設計です。
<script>
import Chart from "chart.js/auto";
let canvas;
let data = $state([1, 2, 3]);
$effect(() => {
const chart = new Chart(canvas, {
type: "bar",
data: { labels: data.map((_, i) => i), datasets: [{ data }] },
});
return () => chart.destroy(); // cleanup(unmount / 再実行前)
});
</script>
<canvas bind:this={canvas}></canvas>
<button onclick={() => data.push(Math.random() * 10)}>追加</button>
$effect.pre ── DOM 更新前に走らせる
<script>
let messages = $state([]);
let box;
let autoscroll = $state(true);
$effect.pre(() => {
if (!box) return;
// 更新後にスクロール末尾だったか事前判定
autoscroll = box.scrollTop + box.clientHeight >= box.scrollHeight - 4;
});
$effect(() => {
if (autoscroll) box.scrollTop = box.scrollHeight;
});
</script>
$state を書き換えると、別の $effect が発火して……という連鎖で無限ループの温床になります。「$derived で書けるものは $derived に寄せる」を鉄則にして、$effect は外部世界との同期(DOM / ストレージ / ネットワーク)に限定するのが健全です。$props ── プロパティの受け取り
<script lang="ts">
interface Props {
title: string;
subtitle?: string;
size?: "sm" | "md" | "lg";
}
let { title, subtitle = "", size = "md" }: Props = $props();
</script>
<h2 class="size-{size}">{title}</h2>
{#if subtitle}<p>{subtitle}</p>{/if}
Rest Props と $props.id()
<script lang="ts">
let { label, ...rest }: { label: string; [k: string]: any } = $props();
const uid = $props.id(); // コンポーネントインスタンス固有の安定 ID
</script>
<label for={uid}>{label}</label>
<input id={uid} {...rest} />
$props.id()(Svelte 5.20+): SSR / CSR の両方で同じ ID を発行するため、aria-labelledby / for のようなアクセシビリティ連携で Hydration 不一致を避けられます。2026 年はこのパターンが標準です。$bindable ── 親へ値を書き戻す
<script lang="ts">
interface Props { value: string; placeholder?: string; }
// $bindable() を付けると親側で bind: が使える
let { value = $bindable(""), placeholder = "" }: Props = $props();
</script>
<input {placeholder} bind:value />
<script>
import TextField from "./TextField.svelte";
let query = $state("");
</script>
<TextField bind:value={query} placeholder="検索..." />
<p>現在の値: {query}</p>
export let value すれば自動で双方向」でしたが、意図しない書き戻しでバグを生みやすい仕様でした。Svelte 5 は $bindable() を書いたプロパティだけが双方向許可されるため、コンポーネントの API が明示的になります。$inspect ── リアクティブなデバッグ出力
<script>
let user = $state({ name: "Alice", age: 25 });
// 値が変わるたびに console に出力(初回は init、以降は update でラベル付け)
$inspect(user);
// 出力方法をカスタム
$inspect(user).with((type, value) => {
if (type === "update") {
console.warn(`user updated:`, value);
}
});
</script>
$inspect は Svelte コンパイラに認識されるため、依存に変化があった時だけ走る最適化が効きます。本番ビルドでは自動的に削除されるので、コードに残っても本番バンドルに影響しません。$host ── カスタム要素内でのホスト要素参照
<svelte:options customElement="my-counter" />
<script>
let count = $state(0);
function dispatch() {
// Custom Element のホスト要素を取得
$host().dispatchEvent(new CustomEvent("tick", { detail: count }));
}
$effect(() => {
const id = setInterval(() => { count++; dispatch(); }, 1000);
return () => clearInterval(id);
});
</script>
<span>{count}</span>
<svelte:options customElement="..."> を付けて Web Component として配布するコンポーネントの中でしか使いません。ホスト要素にイベントを発火させる・属性を読み取る等、Custom Elements API との橋渡しに使います。Snippets と {@render} ── スロットの完全置換
Svelte 5 では <slot> が廃止され、Snippets に置き換わりました。Snippets は「コンポーネント内外で呼び出せる、引数を取れるテンプレート断片」で、スロットより表現力が上がっています。
子要素を受け取る ── children
<script lang="ts">
import type { Snippet } from "svelte";
let { children }: { children: Snippet } = $props();
</script>
<div class="card">
{@render children()}
</div>
<Card> <p>任意のコンテンツ</p> </Card>
名前付きコンテンツ(旧・名前付きスロットの置換)
<script lang="ts">
import type { Snippet } from "svelte";
let {
header,
children,
footer,
}: { header?: Snippet; children: Snippet; footer?: Snippet } = $props();
</script>
<header>{#if header}{@render header()}{/if}</header>
<main>{@render children()}</main>
<footer>{#if footer}{@render footer()}{/if}</footer>
<Layout>
{#snippet header()}
<h1>タイトル</h1>
{/snippet}
本文コンテンツ
{#snippet footer()}
<small>© 2026 codingls</small>
{/snippet}
</Layout>
引数つき Snippet ── リスト描画の再利用
<script lang="ts">
import type { Snippet } from "svelte";
let {
items,
row,
}: { items: Item[]; row: Snippet<[Item, number]> } = $props();
</script>
<ul>
{#each items as item, i}
<li>{@render row(item, i)}</li>
{/each}
</ul>
<List {items}>
{#snippet row(item, i)}
<strong>{i + 1}.</strong> {item.title}
{/snippet}
</List>
{@render snippet(a, b)} で呼び出すだけで再利用できます。同じ {#snippet} を複数箇所で {@render} してもよく、「リスト行・ツールチップ・モーダルの本文」のような部品化に向いています。イベントハンドラとコールバック Props
<!-- Svelte 4 -->
<button on:click={handler} on:click|preventDefault={submit}>
<!-- Svelte 5 -->
<button onclick={handler} onclick={(e) => { e.preventDefault(); submit(); }}>
コンポーネントイベントの新作法
<script lang="ts">
interface Props {
count: number;
onlike?: (count: number) => void; // ← コールバック props
}
let { count, onlike }: Props = $props();
</script>
<button onclick={() => onlike?.(count + 1)}>
♥ {count}
</button>
<LikeButton count={likes} onlike={(n) => likes = n} />
onclick={(e) => (e.preventDefault(), handler())}。CapLock トリガーのような特殊イベントもハンドラ内で判定してください。Universal Reactivity ── .svelte.js / .svelte.ts の威力
Svelte 4 の writable / readable / derived Store は、コンポーネント外でリアクティブを実現するための別 APIでした。Svelte 5 は .svelte.js / .svelte.ts 拡張子のファイル内で $state / $derived / $effect がそのまま動くため、Store という別概念を学ぶ必要がなくなりました。
export type CartItem = { sku: string; qty: number };
function createCart() {
let items = $state<CartItem[]>([]);
let total = $derived(items.reduce((sum, it) => sum + it.qty, 0));
return {
get items() { return items; },
get total() { return total; },
add(sku: string) {
const found = items.find((it) => it.sku === sku);
if (found) found.qty += 1;
else items.push({ sku, qty: 1 });
},
remove(sku: string) {
items = items.filter((it) => it.sku !== sku);
},
clear() { items = []; },
};
}
export const cart = createCart();
<script>
import { cart } from "./cart.svelte.ts";
</script>
<p>カート: {cart.total} 点</p>
<button onclick={() => cart.add("SKU-001")}>追加</button>
{#each cart.items as item (item.sku)}
<li>{item.sku} x {item.qty}</li>
{/each}
writable は暫く残りますが、新規はすべて .svelte.ts + $state で書き、既存 Store は段階的に置換するのが王道。型推論も Runes の方が自然に効きます。TypeScript での型付けパターン
type User = { id: string; name: string; email: string };
export function createUserState() {
let user = $state<User | null>(null);
let loading = $state(false);
let error = $state<Error | null>(null);
async function load(id: string) {
loading = true; error = null;
try {
const res = await fetch(`/api/users/${id}`);
user = (await res.json()) as User;
} catch (e) {
error = e as Error;
} finally {
loading = false;
}
}
return {
get user() { return user; },
get loading() { return loading; },
get error() { return error; },
load,
};
}
<script lang="ts" generics="T">
import type { Snippet } from "svelte";
interface Props<T> {
items: T[];
row: Snippet<[T, number]>;
}
let { items, row }: Props<T> = $props();
</script>
<ul>
{#each items as item, i}<li>{@render row(item, i)}</li>{/each}
</ul>
Svelte 4 → 5 移行手順
# 1) Svelte 5 にアップグレード npm install svelte@5 @sveltejs/kit@latest # TypeScript を使っているなら npm install -D typescript@latest svelte-check@latest # 2) 公式マイグレーションスクリプト(破壊的に書き換える) npx sv migrate svelte-5 # 3) 差分確認(必ず git diff でレビュー) git diff # 4) 開発サーバーで警告を拾う npm run dev # 5) 型チェック npm run check
自動で置換される範囲
let count = 0;→let count = $state(0);(テンプレートで参照されている場合のみ)$: double = count * 2;→let double = $derived(count * 2);$: { /* 副作用 */ }→$effect(() => { /* 副作用 */ });export let title;→let { title } = $props();on:click→onclick(基本ケース)<slot />→{@render children()}
手作業が残る範囲
createEventDispatcher→ コールバック props(API 設計変更なので手作業)- イベント修飾子(
|preventDefault等)→ ハンドラ内で実装 beforeUpdate/afterUpdate→$effect.pre/$effect(意図判定が難しい)- Store を使っていたコード →
.svelte.ts+$state化(任意、徐々に) - 複雑な
$:ロジック(代入と副作用の混在)→$derivedと$effectへの分離
npx sv migrate svelte-5 も 1 ディレクトリごとに走らせて diff をレビューしていきます。SvelteKit と Svelte 5
SvelteKit 2(2024〜)以降は Svelte 5 を正式サポートし、+page.svelte / +layout.svelte / +page.server.ts のすべてで Runes が使えます。ルーティング・フォームアクション・ロード関数は変わらず、コンポーネント内の書き方だけが Runes に変わったと捉えると分かりやすいです。
<script lang="ts">
import type { PageData, ActionData } from "./$types";
let { data, form }: { data: PageData; form: ActionData } = $props();
// data.items は PageData から型推論される
let filter = $state("");
let filtered = $derived(
data.items.filter((it) => it.title.includes(filter))
);
</script>
<input bind:value={filter} placeholder="検索..." />
<form method="POST" action="?/create" use:enhance>
<input name="title" required />
<button>作成</button>
{#if form?.error}<p class="err">{form.error}</p>{/if}
</form>
{#each filtered as it (it.id)}
<article>{it.title}</article>
{/each}
落とし穴と注意点
$state は Proxy なので参照等価性が変わる
const a = { x: 1 }; const b = $state(a); の時、a !== b です。DOM API や外部ライブラリに「同じオブジェクトを渡した」はずなのに別物扱いされる事故が起きます。必要なら $state.snapshot(b) で素の値を取り出してください。
$effect 内で state を書き換える無限ループ
$effect の中で count += 1 を書くと、その $effect が再度発火して……と無限ループになります。Svelte はある程度検出してくれますが、原則「$effect では state を変えない」を守るのが安全です。値の派生は $derived に寄せます。
コンポーネント外で $state を使うには .svelte.js / .svelte.ts 拡張子が必要
普通の .ts や .js では Runes は使えません(コンパイラが処理しない)。ファイル名を cart.svelte.ts にするだけで有効化されます。VSCode / svelte-check の型エラーが出ないのに実行時に動かない場合は、まずファイル名を確認してください。
getter / setter を忘れると state が固定化する
return { count, increment }; と書くと、return した時点の count 値がスナップショットされて固定されます。必ず return { get count() { return count; }, increment }; のように getter で包みます。
Svelte 4 のイベント修飾子が無くなっている
on:click|preventDefault のような修飾子記法が 5 では動きません。自動マイグレーションでも警告のまま残るケースがあるので、検索して置換してください。
旧 Stores(writable)と Runes の混在
Svelte 4 Stores と Runes は共存できますが、データの同一ソースを両方で扱うと混乱します。移行時は「1 つの状態は Store か Runes のどちらかに寄せる」を徹底し、徐々に Runes へ統一していきます。
よくある質問
<svelte:options runes={true} /> を書かないコンポーネントは Svelte 4 互換モードで動作します。useState / useMemo / useEffect / useContext に相当する機能を $state / $derived / $effect で表現できるため、概念の対応は非常にスムーズです。React 19 の詳細は React 19 完全ガイド を参照してください。npx sv create で作成すると Svelte 5 + SvelteKit 2 最新構成で生成されます。既存 SvelteKit プロジェクトは svelte と @sveltejs/kit の両方を最新にし、npx sv migrate svelte-5 を走らせるのが早道です。.ts では使えず、.svelte.ts 拡張子のファイル内でのみ有効です。これはコンパイラが .svelte.* の付いたファイルだけを処理対象にしているためです。共通ロジックを Runes 化する際は、ファイルを xxx.svelte.ts にリネームしてから $state を使う運用になります。{#snippet name(a, b)}...{/snippet} で定義し、{@render name(1, 2)} で呼び出す一貫した API になっています。ComponentProps<typeof MyComponent> で props の型を取り出せます。Svelte 5 は内部的にコンポーネントを関数として扱うため、型レベルの連携が React に近い形になりました。Snippet の型は Snippet<[引数タプル]> で定義できます。npx astro add svelte でインテグレーションを追加すると、Svelte 5 コンポーネントを client:load などで島として埋め込めます。Astro 全体の設計は Astro 完全ガイド を参照してください。コンテンツ主体のページは Astro、インタラクティブな島を Svelte で書く組合せは、バンドルサイズを最小化できる 2026 年の強力な構成です。まとめ
- Svelte 5 の本質は Runes による「明示的・ランタイム・ユニバーサルなリアクティビティ」。let 変数の自動反応という魔法を捨て、$state で明示する設計に
- 7 つの Runes: $state / $derived / $effect / $props / $bindable / $inspect / $host。$derived は関数切り出しても依存追跡が効くのが Svelte 4 との大差
- Snippets と {@render} がスロットを置換。引数を渡せて名前付きにできる、React の render prop 相当が公式機能に
- Universal Reactivity:
.svelte.js/.svelte.tsで $state をそのまま書ける。Store は事実上不要 - イベントは onclick などの属性形式。
on:clickと修飾子(|preventDefault 等)は廃止。createEventDispatcherもコールバック props に - 移行は
npx sv migrate svelte-5で自動化できる部分を先に処理し、createEventDispatcher/beforeUpdate/ イベント修飾子は手作業 - 落とし穴は Proxy の参照非等価・$effect の無限ループ・.svelte.ts 拡張子の忘れ・getter 不在の state 固定化
関連記事として React 19 完全ガイド、Astro 完全ガイド、Bun 完全ガイド、TypeScript × Hono 完全ガイド、TypeScript × Drizzle ORM 完全ガイド、Claude Code × Supabase 完全ガイド もあわせて、Svelte 5 を核に据えた 2026 年型軽量フロントエンド構成を組み上げてください。
