【IT】忘れ物防止!かわいいToDoアプリを作ってみた(SvelteKitハンズオン)
皆さま、こんにちは
最近よく耳にする PWA(Progressive Web App)。
スマホのホーム画面に追加でき、まるでネイティブアプリのように動く便利な仕組みです。
でも実際に自分で作ろうとすると、
「manifest.jsonってどこで書くの?」
「Service Workerって難しそう…」
「デプロイ後に本当にPWAになるの?」
といった疑問で立ち止まってしまいがち。
そこで今回は、SvelteKit × Tailwind CSS × VitePWA を使って、
学習しやすい題材=ToDoアプリを作ってみました。
今回の完成イメージ(無料パート)

前半(この記事の無料パート)では:
Node / npm / VS Code での環境構築
SvelteKit プロジェクトの作成
最小限の ToDo アプリ(追加・削除・完了フラグ付き)
を一緒に体験していきます。
そして後半(有料パート)では、
背景デザインやアイコンを加えて「かわいい」アプリに進化
localStorage 保存でリロードしてもデータ保持
PWA 対応と Vercel デプロイ
実際にスマホで「ホーム画面に追加」できる体験
まで、実際のハマりどころやコマンド例を交えながら伴走スタイルで解説します。
環境設定
今回の開発環境は以下の通りです。
macOS(Apple Silicon)
Node.js v22.x (LTS推奨)
npm v11.x
VS Code 最新版
👉 Windows は今回は考慮していません。
Node.js 、npmが入っているか確認するには:
node -v
npm -v
出力例;
$ node -v
v22.12.0
$ npm -v
11.5.2バージョンが表示されればOKです。
バージョンが表示されない場合は、インストールください。
インストール方法は以下のリンクをご参照ください。
SvelteKit の導入
SvelteKit の新規プロジェクトを作成します。
Svelte公式の Svelte CLI を使って雛形を作成します。
※Tailwind → 「Svelte CLIで選べば追加作業なし」
$ npx sv create my-todo-app
┌ Welcome to the Svelte CLI! (v0.9.7)
│
◇ Which template would you like?
│ SvelteKit minimal
│
◇ Add type checking with TypeScript?
│ Yes, using TypeScript syntax
│
◆ Project created
│
◇ What would you like to add to your project? (use arrow keys / space bar)
│ prettier, tailwindcss
│
◇ tailwindcss: Which plugins would you like to add?
│ none
│
◆ Successfully setup add-ons
│
◇ Which package manager do you want to install dependencies with?
│ npm
│
◆ Successfully installed dependencies
│
◇ Successfully formatted modified files
│
◇ What's next? ───────────────────────────────╮
│ │
│ 📁 Project steps │
│ │
│ 1: cd my-todo-app │
│ 2: npm run dev -- --open │
│ │
│ To close the dev server, hit Ctrl-C │
│ │
│ Stuck? Visit us at https://svelte.dev/chat │
│ │
├──────────────────────────────────────────────╯
│
└ You're all set!
cd my-todo-app
npm run dev -- --open 先ずは、起動するか確認します。

SvelteKit でシンプル ToDo 作成
`src/routes/+page.svelte` に ToDo リストを記述します。
ここでは 最小限 のコードで実装します。
<script lang="ts">
import { browser } from '$app/environment'; // ← SSR中かブラウザ中かを判定するためのフラグ
// 入力欄に双方向バインドするので、Runesの $state で宣言することが大事
// これが通常の let '' だと「Can only bind to state or props」になります
let input = $state('');
// ToDo の型(id/text/done は最小限。id はeachキーにも使うので必須)
type Todo = { id: string; text: string; done: boolean };
// ★ 既存データを正規化して id を補完してから状態に入れる
// - 旧バージョンの保存データ(`{text, done}` で idなし)を読み込んだ場合でも、初回ロード時に自動で `id` を採番して整えます。`each_key_duplicate` を未然に防ぎます。
// - その「整えた結果」を localStorage に書き戻すので、以後は常に id 付きで保存される
let todos: Todo[] = $state(
browser // SSR中は localStorage に触れない(未定義なので)
? (() => {
try {
const raw = JSON.parse(localStorage.getItem('todos-min') ?? '[]') as any[];
const normalized = Array.isArray(raw)
? raw.map((x) => ({
id: x?.id ?? crypto.randomUUID(), // ← 無ければ採番(duplicate key を防ぐ)
text: String(x?.text ?? ''), // ← 不正な型でも文字列に丸める
done: !!x?.done // ← 真偽値に丸める
}))
: [];
// 正規化後を書き戻し(次回以降の読み込みも安定)
localStorage.setItem('todos-min', JSON.stringify(normalized));
return normalized;
} catch {
// 壊れたJSON等は握りつぶして空配列に
return [];
}
})()
: []
);
// 変更のたび保存:todos が変わると $effect が再実行される
// v5では依存は自動トラッキングされるので、todos を参照するだけでOK
$effect(() => {
if (browser) localStorage.setItem('todos-min', JSON.stringify(todos));
});
// フィルタ状態: all | active(未完了) | done(完了)
// UIから書き換えるので $state で宣言
let filter = $state<'all' | 'active' | 'done'>('all');
// 表示用の導出リスト(Runes: 関数ではなく“式”で書くのがポイント)
// NG: $derived(() => todos.filter(...))
// OK: $derived( todos.filter(...) )
const visible = $derived(
filter === 'all'
? todos
: filter === 'active'
? todos.filter((t) => !t.done)
: todos.filter((t) => t.done)
);
// 簡易統計(やる気向上のための進捗表示)
const stats = $derived({
total: todos.length,
done: todos.filter((t) => t.done).length,
rate: todos.length ? Math.round((todos.filter((t) => t.done).length / todos.length) * 100) : 0
});
// 追加:空白は無視、先頭に新規を積む(モバイルで最新が上に来る方が扱いやすい)
function addTodo() {
const t = input.trim();
if (!t) return;
todos = [{ id: crypto.randomUUID(), text: t, done: false }, ...todos];
input = '';
}
// 完了トグル:不変更新(新しい配列にして代入)→ 反応性が確実に働く
function toggle(id: string) {
todos = todos.map((x) => (x.id === id ? { ...x, done: !x.done } : x));
}
// 削除:id 以外は等価でも、配列の同一性は気にせず filter でOK
function removeTodo(id: string) {
todos = todos.filter((x) => x.id !== id);
}
</script>
<div class="mx-auto max-w-md p-4 space-y-4">
<h1 class="text-xl font-bold">シンプルToDo(保存つき)</h1>
<!-- 進捗(やる気を出す小さなフィードバック) -->
<div class="text-sm text-gray-700">進捗: {stats.done}/{stats.total}({stats.rate}%)</div>
<!-- 入力フォーム(v5推奨: onsubmit。修飾子は使えないので e.preventDefault() を自前で) -->
<form
class="flex gap-2"
onsubmit={(e: SubmitEvent) => {
e.preventDefault(); // ← これが |preventDefault の代わり
addTodo();
}}
>
<!-- bind:value の相手は $state の input。通常の let '' だとエラーになります -->
<input
class="flex-1 rounded border p-2"
placeholder="やることを書いて…"
bind:value={input}
/>
<button class="rounded bg-pink-500 text-white px-4 py-2">追加</button>
</form>
<!-- フィルタセレクター(三分割トグル)。選択中だけ色を変える -->
<!-- スマホ前提で三分割ボタンにしています(選択中のみピンクに反転)。-->
<div class="flex rounded-lg overflow-hidden border w-full">
<button
class="flex-1 px-3 py-2 text-sm"
class:bg-pink-500={filter === 'all'}
class:text-white={filter === 'all'}
onclick={() => (filter = 'all')}
>
すべて
</button>
<button
class="flex-1 px-3 py-2 text-sm border-l"
class:bg-pink-500={filter === 'active'}
class:text-white={filter === 'active'}
onclick={() => (filter = 'active')}
>
未完了
</button>
<button
class="flex-1 px-3 py-2 text-sm border-l"
class:bg-pink-500={filter === 'done'}
class:text-white={filter === 'done'}
onclick={() => (filter = 'done')}
>
完了
</button>
</div>
<!-- 一覧は visible を描画。キーは (t.id) で安定化(duplicate key 回避の要) -->
<ul class="space-y-2">
{#each visible as t (t.id)}
<li class="flex items-center gap-2 rounded border p-2">
<!-- v5スタイル: onchange。チェック状態は checked={t.done} を明示 -->
<input type="checkbox" checked={t.done} onchange={() => toggle(t.id)} />
<!-- class:line-through の“クラス指向”バインディングで完了時の打消し線 -->
<span class:line-through={t.done}>{t.text}</span>
<button class="ml-auto text-xs text-gray-500" onclick={() => removeTodo(t.id)}>
削除
</button>
</li>
{/each}
</ul>
</div>
<style>
/* 完了タスクのビジュアルだけ軽く */
.line-through { text-decoration: line-through; opacity: .6; }
</style>
無事起動していることを確認しまうす。
よくあるハマり & ここで防いでいるポイント
`bind:value` の相手が `$state` じゃない →
`Can only bind to state or props`。今回 `input = $state('')` で回避。`on:submit|preventDefault` を使っている →
Svelte v5 では `on:submit` は非推奨。`onsubmit` にして `e.preventDefault()` を自前で呼びます。`each_key_duplicate` が出る →
古い保存に `id` がないと `(t.id)` が `undefined` になり重複。今回、初回ロード時に正規化して id を採番。SSRで `localStorage` に触れて落ちる →
`browser` ガードでクライアント時のみ読み書き。
デバッグのヒント
正規化が走っているか確認したいとき:
Application → Local Storage → `todos-min` を開いて id が付与されているか確認。
👉 これで、ブラウザ上で 追加・完了・削除 ができる最小の ToDo アプリが完成します。
無料パートのゴール
ここまでで SvelteKitを動かす環境と、最小のToDoアプリができました。
リロードしてもToDoは残ります(localStorage)。
まずは SvelteKitの基本的な開発サイクルを体験できたはずです。
有料パート部分での内容案内
ここから先は、有料パートで丁寧に解説していきます。
🎨 背景にかわいいイラストを配置してデザインを整える
💾 localStorageでリロードしてもデータ保持
📱 PWA対応で「ホーム画面に追加」
☁️ Vercelにデプロイして実際に家族で使えるように
ここからは“使いたくなるアプリ”に育てます。見た目・保存の質・PWA体験を一気に底上げを行っていきます。
この無料パートで「作って動く」ことを体験できました。
でも実際に家族に配って使ってもらうには、見た目のかわいさ・ホーム追加できるPWA対応・公開デプロイが必要です。
「シンプルなToDo」から「見た目も楽しい、配れるToDo」へ。
その工程を一緒に進めていきましょう。
👉 続きは有料パートで、伴走しながら一緒に完成形まで進めましょう。
ここから先は
¥ 980
この記事が気に入ったらチップで応援してみませんか?
