このチュートリアルで学ぶこと
- ✓ Svelteプロジェクトのセットアップ (Vite)
- ✓ コンポーネントとprops
- ✓ リアクティビティ (runes含む)
- ✓ イベント処理と双方向バインディング
- ✓ ストアによる状態管理
- ✓ 条件付き / ループレンダリング
前提条件
- HTML / CSS / JavaScript の基礎
- Node.js 20 以上
- npm または pnpm の使い方
- コンポーネント指向UIの概念を少しでも知っていると理解しやすい
プロジェクトのセットアップ
# Vite + Svelte テンプレート
npm create vite@latest my-svelte-app -- --template svelte-ts
cd my-svelte-app
npm install
npm run dev
ディレクトリ構造
my-svelte-app/
├── src/
│ ├── App.svelte
│ ├── main.ts
│ ├── lib/
│ │ └── Counter.svelte
│ └── app.css
├── index.html
├── svelte.config.js
├── vite.config.ts
└── tsconfig.json
ポイント: Svelteはコンパイル時に最適化されるため、仮想DOMを使わず直接DOMを操作します。バンドルサイズが小さく高速なのが最大の強みです。
基本概念
Svelteは「実行時フレームワーク」ではなく「コンパイラ」です。
.svelte ファイルはビルド時に最適化されたVanilla JSへ変換されるため、ランタイムが非常に小さく、
起動が速く、バンドルサイズも小さくなります。Svelte 5からは runes と呼ばれる新しいリアクティビティAPIが導入されました。
.svelte → コンパイラ → 最小限のJS + DOM操作
Step 1: 最初のコンポーネント
Svelteのコンポーネントは.svelteファイルにscript、markup、styleをまとめて記述します。
<!-- src/App.svelte -->
<script lang="ts">
let name = "Svelte";
</script>
<main>
<h1>こんにちは、{name}!</h1>
<input bind:value={name} />
</main>
<style>
main {
font-family: sans-serif;
padding: 2rem;
}
h1 {
color: #ff3e00;
}
</style>
bind:value により input と変数が双方向で同期します。
実践メモ: Svelte 5のrunes($state, $derived, $effect)を使うと、より明示的で予測可能なリアクティビティが実現できます。新規プロジェクトではrunesの利用を推奨します。
Step 2: runesを使ったリアクティビティ (Svelte 5)
<!-- src/lib/Counter.svelte -->
<script lang="ts">
let count = $state(0);
let doubled = $derived(count * 2);
$effect(() => {
console.log(`countが変更されました: ${count}`);
});
function increment() {
count += 1;
}
</script>
<button onclick={increment}>
カウント: {count}
</button>
<p>2倍: {doubled}</p>
$stateで宣言した値は変更を追跡される$derivedは依存値から算出される値$effectは副作用を実行 (マウント後に走る)
Step 3: propsとコンポーネント合成
<!-- src/lib/Card.svelte -->
<script lang="ts">
type Props = {
title: string;
description?: string;
children?: any;
};
let { title, description = "", children }: Props = $props();
</script>
<article>
<h2>{title}</h2>
{#if description}
<p>{description}</p>
{/if}
{@render children?.()}
</article>
<style>
article {
border: 1px solid #ccc;
border-radius: 8px;
padding: 1rem;
margin: 0.5rem 0;
}
</style>
<!-- src/App.svelte -->
<script lang="ts">
import Card from "./lib/Card.svelte";
import Counter from "./lib/Counter.svelte";
</script>
<Card title="Svelte学習" description="コンパイラ型UIフレームワーク">
{#snippet children()}
<Counter />
{/snippet}
</Card>
Step 4: 条件とループ
<script lang="ts">
type Todo = { id: number; text: string; done: boolean };
let todos = $state<Todo[]>([
{ id: 1, text: "Svelteを学ぶ", done: true },
{ id: 2, text: "runesを理解する", done: false },
{ id: 3, text: "アプリを作る", done: false },
]);
let newText = $state("");
function addTodo() {
if (newText.trim() === "") return;
todos = [...todos, { id: Date.now(), text: newText, done: false }];
newText = "";
}
function toggle(id: number) {
todos = todos.map((t) =>
t.id === id ? { ...t, done: !t.done } : t
);
}
function remove(id: number) {
todos = todos.filter((t) => t.id !== id);
}
</script>
<h2>ToDo リスト</h2>
<form onsubmit={(e) => { e.preventDefault(); addTodo(); }}>
<input bind:value={newText} placeholder="新しいタスク" />
<button type="submit">追加</button>
</form>
{#if todos.length === 0}
<p>タスクがありません</p>
{:else}
<ul>
{#each todos as todo (todo.id)}
<li class:done={todo.done}>
<input type="checkbox" checked={todo.done} onchange={() => toggle(todo.id)} />
{todo.text}
<button onclick={() => remove(todo.id)}>×</button>
</li>
{/each}
</ul>
{/if}
<style>
.done {
text-decoration: line-through;
color: gray;
}
</style>
注意: writableストアはグローバルな状態管理に便利ですが、多用しすぎるとデータフローが追いにくくなります。コンポーネントpropsで済む場合はストアを使わないのが原則です。
Step 5: グローバルストア
// src/lib/stores/user.svelte.ts
type User = { name: string; loggedIn: boolean };
function createUserStore() {
let state = $state<User>({ name: "", loggedIn: false });
return {
get value() {
return state;
},
login(name: string) {
state = { name, loggedIn: true };
},
logout() {
state = { name: "", loggedIn: false };
},
};
}
export const userStore = createUserStore();
<!-- src/lib/Header.svelte -->
<script lang="ts">
import { userStore } from "./stores/user.svelte";
</script>
<header>
{#if userStore.value.loggedIn}
<span>ようこそ {userStore.value.name} さん
<button onclick={() => userStore.logout()}>ログアウト</button>
{:else}
<button onclick={() => userStore.login("太郎")}>ログイン</button>
{/if}
</header>
Step 6: 非同期データ取得
<script lang="ts">
type Post = { id: number; title: string };
async function fetchPosts(): Promise<Post[]> {
const res = await fetch("https://jsonplaceholder.typicode.com/posts?_limit=5");
if (!res.ok) throw new Error("取得失敗");
return res.json();
}
let promise = $state(fetchPosts());
function reload() {
promise = fetchPosts();
}
</script>
<button onclick={reload}>再読み込み</button>
{#await promise}
<p>読み込み中...</p>
{:then posts}
<ul>
{#each posts as post}
<li>{post.title}</li>
{/each}
</ul>
{:catch error}
<p style="color: red">{error.message}</p>
{/await}
完成コード: ミニメモアプリ
<!-- src/App.svelte -->
<script lang="ts">
type Memo = { id: string; title: string; body: string; updatedAt: number };
let memos = $state<Memo[]>([]);
let title = $state("");
let body = $state("");
function save() {
if (!title.trim()) return;
memos = [
{ id: crypto.randomUUID(), title, body, updatedAt: Date.now() },
...memos,
];
title = "";
body = "";
}
function remove(id: string) {
memos = memos.filter((m) => m.id !== id);
}
let sorted = $derived(
[...memos].sort((a, b) => b.updatedAt - a.updatedAt)
);
</script>
<section>
<h1>メモ</h1>
<input bind:value={title} placeholder="タイトル" />
<textarea bind:value={body} placeholder="内容"></textarea>
<button onclick={save}>保存</button>
<ul>
{#each sorted as memo (memo.id)}
<li>
<strong>{memo.title}</strong>
<p>{memo.body}</p>
<small>{new Date(memo.updatedAt).toLocaleString()}</small>
<button onclick={() => remove(memo.id)}>削除</button>
</li>
{/each}
</ul>
</section>
よくあるエラーと対処
- “Cannot assign to import” エラー
- ストアは関数でラップして getter/setter を提供する
- $state の再代入がUIに反映されない
- 配列/オブジェクトは新しい参照を代入する (push だけでは検知できない場合あり)
- runes の使用で TypeScript エラー
- svelte-check と @sveltejs/vite-plugin-svelte を最新に
- {#each} で key を指定せず DOM が崩れる
- {#each items as item (item.id)} のように key を指定
- onclick で () => func(arg) ではなく func(arg) と書いて即時実行される
- 必ず関数を渡す: onclick={() => func(arg)}
ベストプラクティス
- runes API ($state/$derived/$effect) を積極的に使う
- コンポーネントは小さく、責務を明確に
- グローバル状態はストアに集約
- $effect は本当に副作用が必要な場合のみ使用
- CSSはコンポーネントにスコープされる特性を活かす
- ブラウザAPIに依存するコードは onMount 相当の $effect 内で
次のステップ
- SvelteKit (公式フルスタックフレームワーク)
- Svelteのトランジション・アニメーション
- TypeScriptとの深い統合
- テスト (Vitest + @testing-library/svelte)
- デプロイ (Vercel / Netlify / Cloudflare Pages)
ここまでの整理
Svelteはコンパイル時に最適化を行うため、ランタイムが小さく高速です。 runes APIの導入でより明示的で予測可能なリアクティビティを手に入れ、 学習コストも低いため小〜中規模アプリに特に向いています。
FAQ
Q. Svelte 4 と Svelte 5 の違いは?
A. Svelte 5 では runes ($state / $derived / $effect / $props) が導入され、
リアクティビティがより明示的になりました。既存のSvelte 4コードとも互換性がありますが、
新規開発はrunes前提で学ぶのがおすすめです。
Q. React との主な違いは?
A. ReactはVirtual DOMを使うランタイム中心、Svelteはコンパイル時に最適化するコンパイラ中心です。
Svelteは .svelte という単一ファイルにHTML/CSS/JSをまとめ、記述量が少なくなる傾向にあります。
Q. SvelteKit とは? A. Next.jsに相当する公式フルスタックフレームワークです。 ルーティング、サーバ機能、SSR/SSG/SPAの切替などが統合されています。
Q. 状態管理ライブラリは必要?
A. 小〜中規模ではストアパターンで十分です。大規模になる場合でも getContext / setContext や
runes を使った独自ストアで十分カバーできます。
チートシート
// Svelte 5 / Vite
// https://svelte.dev/docs
$state(value) // リアクティブな状態
$derived(expr) // 算出値
$effect(() => {...}) // 副作用
$props() // コンポーネントのprops
bind:value // 双方向バインディング
on* // onclick, oninput // イベントハンドラ (Svelte 5)
{#if} {:else} // 条件レンダリング
{#each items as item} // ループ
{#await promise} // 非同期レンダリング
{@render snippet()} // スニペット呼び出し
まとめ
この記事では、手順を追って動かしながら重要な確認ポイントを押さえることが大切です。まずは学習用の最小構成で動作確認し、本番利用する前にセキュリティ、権限、エラー処理、運用手順を見直してください。
参考リソース
補足: スタイルとトランジション
Svelteはコンポーネントの <style> が自動的にスコープされるため、
クラス名の衝突を気にせずCSSを書けます。
<script lang="ts">
import { fade, fly } from "svelte/transition";
let visible = $state(true);
</script>
<button onclick={() => (visible = !visible)}>
トグル
</button>
{#if visible}
<div transition:fly={{ y: 20, duration: 300 }}>
フライトイン / アウト
</div>
{/if}
{#if visible}
<div transition:fade>
フェード
</div>
{/if}
transition: ディレクティブを使うことで、DOMが現れる / 消えるタイミングに
滑らかなアニメーションを追加できます。in: / out: を使って入退場を別々に指定することも可能です。