SvelteでTodoアプリを作る

## 完成図

https://svelte-todo-app-hjta.vercel.app

![ScreenShot 2024-12-30 11.06.00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/52879/ead79652-6f4c-dff2-a4df-265ceb616f02.png)

## miseでBunセットアップ

https://qiita.com/ucan-lab/items/f7f010ee2d13ab99203c

```shell
$ mise use -g bun@1.1.42
$ bun -v
```

## Todoプロジェクトの作成

```
$ bunx sv create svelte-todo-app
┌  Welcome to the Svelte CLI! (v0.6.10)
│
◆  Which template would you like?
│  # プロジェクトテンプレートをどうするか。今回は minimal を選択する
│  ● SvelteKit minimal (barebones scaffolding for your new app)
│  ○ SvelteKit demo
│  ○ Svelte library
│
◇  Add type checking with Typescript?
│  # TypeScriptを使うか？ 特に使わない選択肢はないと思うのでYes
│  ● Yes, using Typescript syntax
│  ○ Yes, using Javascript with JSDoc comments
│  ○ No
│
◇  What would you like to add to your project? (use arrow
keys / space bar)
|  # プロジェクトに追加したいツールはあるか
|  # デモアプリに必要ないと思うが、コードフォーマッターと静的解析ツールを選択
│  ◼ prettier (formatter - https://prettier.io) # コードフォーマッター
│  ◼ eslint # JavaScript/TypeScript用の静的解析ツール
│  ◻ vitest # ユニットテストフレームワーク
│  ◻ playwright # エンドツーエンド(E2E)テスト用フレームワーク
│  ◻ tailwindcss # CSSフレームワーク
│  ◻ sveltekit-adapter # SvelteKitプロジェクトのビルド先を指定するためのアダプター
│  ◻ drizzle # TypeScript対応の軽量ORM
│  ◻ lucia # 認証ライブラリ
│  ◻ mdsvex # MarkdownとSvelteを統合するためのプラグイン
│  ◻ paraglide # Svelteアプリ用の国際化(i18n)ライブラリ
│  ◻ storybook # UIコンポーネントを個別に開発・テストするためのツール
│
◆  Which package manager do you want to install dependencies
with?
|  # パッケージマネージャを選択。今回はbunを使用する
│  ○ None
│  ○ npm
│  ○ yarn
│  ○ pnpm
│  ● bun
│  ○ deno
│
◆  Successfully setup add-ons
│
◆  Successfully installed dependencies
│
◇  Successfully formatted modified files
│
◇  Project next steps ─────────────────────────────────────────────────────╮
│                                                                          │
│  1: cd svelte-todo-app                                                   │
│  2: git init && git add -A && git commit -m "Initial commit" (optional)  │
│  3: bun run dev --open                                                   │
│                                                                          │
│  To close the dev server, hit Ctrl-C                                     │
│                                                                          │
│  Stuck? Visit us at https://svelte.dev/chat                              │
│                                                                          │
├──────────────────────────────────────────────────────────────────────────╯
│
└  You're all set!
```

SvelteKitを起動する

```
$ cd svelte-todo-app
$ git init && git add -A && git commit -m "Initial commit"
$ bun dev --open
```

http://localhost:5173

![ScreenShot 2024-12-30 4.23.09.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/52879/11f23703-5425-2894-154f-c6f20593ff37.png)

## インデントをタブからスペースに変更

個人の好みですが、インデントはスペース派なので変更してます。

```diff:.prettierrc
{
-	"useTabs": true,
+	"useTabs": false,
	"singleQuote": true,
	"trailingComma": "none",
	"printWidth": 100,
	"plugins": ["prettier-plugin-svelte"],
	"overrides": [
		{
			"files": "*.svelte",
			"options": {
				"parser": "svelte"
			}
		}
	]
}
```

```shell
$ bun run format
```

## 便利コマンド一覧

```shell
$ bun run dev # Viteのローカル開発用サーバーを起動。自動リロード機能
$ bun run build # プロジェクトを本番用にビルド。distディレクトリにビルド成果物が出力
$ bun run preview # ビルド済みプロジェクトのプレビュー
$ bun run check # プロジェクトの型や設定の整合性をチェック
$ bun run check:watch # 型チェックの監視モードで実行
$ bun run format # コードフォーマットを実行
$ bun run lint # コードの静的解析とフォーマットチェックを実行
```

## シンプルなTodoアプリを作る

`src/routes/+page.svelte` ファイルに次のコードを記述します。

```svelte:src/routes/+page.svelte
<script lang="ts">
  type Todo = {
    text: string;
    done: boolean;
  };

  let todos: Todo[] = $state([
    { text: 'Apple', done: false },
    { text: 'パン', done: false },
    { text: '牛乳', done: true },
    { text: 'Coffee', done: false }
  ]);
  let newTodo: string = $state('');

  function resetInput() {
    newTodo = '';
  }

  function addTodo() {
    if (newTodo.trim()) {
      todos.push({ text: newTodo, done: false });
      resetInput();
    }
  }

  function deleteTodo(index: number) {
    todos.splice(index, 1);
  }
</script>

<main>
  <h1>Todo App</h1>
  <input
    type="text"
    bind:value={newTodo}
    placeholder="New task..."
    onkeydown={(e) => {
      if (e.key === 'Enter' && !e.isComposing) {
        addTodo();
      }
    }}
  />
  <button onclick={addTodo} disabled={!newTodo.trim()}>Add</button>

  <ul>
    {#each todos as todo, i}
      <li>
        <input type="checkbox" bind:checked={todo.done} />
        <span class={todo.done ? 'done' : ''}>{todo.text}</span>
        <button onclick={() => deleteTodo(i)}>Delete</button>
      </li>
    {/each}
  </ul>
</main>

<style>
  span.done {
    text-decoration: line-through;
  }

  button[disabled] {
    opacity: 0.5;
    cursor: not-allowed;
  }
</style>
```

http://localhost:5173

![ScreenShot 2024-12-30 6.17.40.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/52879/9207c47d-a162-30a1-bc0a-228610d694b9.png)

## Todo型の切り出し

Todo型を別ファイルに切り出して再利用可能にすることで、将来的な拡張性を高められます。
`src/types/todo.ts` と新しく型ファイルを作成してインポートしてみます。

```ts:src/types/todo.ts
export type Todo = {
  text: string;
  done: boolean;
};
```

```diff:src/routes/+page.svelte
<script lang="ts">
-  type Todo = {
-    text: string;
-    done: boolean;
-  };
+  import type { Todo } from '../types/todo';
...
```

## データの永続化

現状ではTodoリストがメモリ内にのみ保持され、ページをリロードするとTodoリストがリセットされます。

ブラウザのlocalStorageを使ってデータを永続化します。

```diff:src/routes/+page.svelte
 <script lang="ts">
   import type { Todo } from '../types/todo';
+  import { onMount } from 'svelte';
 
-  let todos: Todo[] = $state([
-    { text: 'Apple', done: false },
-    { text: 'パン', done: false },
-    { text: '牛乳', done: true },
-    { text: 'Coffee', done: false }
-  ]);
+  let todos = $state([] as Todo[]);
   let newTodo: string = $state('');
+  let isInitialized: boolean = $state(false);
+
+  onMount(() => {
+    if (typeof window !== 'undefined') {
+      try {
+        const savedTodos = localStorage.getItem('todos');
+        if (savedTodos) {
+          todos = JSON.parse(savedTodos);
+        }
+      } catch (e) {
+        console.error('Failed to load todos from localStorage:', e);
+      } finally {
+        isInitialized = true;
+      }
+    }
+  });
+
+  $effect(() => {
+    if (isInitialized && typeof window !== 'undefined') {
+      try {
+        localStorage.setItem('todos', JSON.stringify(todos));
+      } catch (e) {
+        console.error('Failed to save todos to localStorage:', e);
+      }
+    }
+  });

   function resetInput() {
     newTodo = '';
   }
```

- onMountを使い、localStorageからデータを復元
- $stateでtodos、newTodo、isInitializedをリアクティブな状態として管理
- $effectを利用して、todosが変更されるたびにlocalStorageに保存
- 初期化完了(isInitializedがtrue)後にのみ保存処理を実行
- ハードコードな初期値を削除

## スタイルの強化

Tailwind CSSフレームワークを導入してTodoアプリの見た目を少しオシャレにします。

https://tailwindcss.com/docs/guides/sveltekit

```shell
$ bun install -d tailwindcss postcss autoprefixer
$ bunx tailwindcss init -p
$ bun run format
```

`svelte.config.js` は特に変更ありません。
次のデフォルトの内容です。

```js:svelte.config.js
import adapter from '@sveltejs/adapter-auto';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';

/** @type {import('@sveltejs/kit').Config} */
const config = {
  // Consult https://svelte.dev/docs/kit/integrations
  // for more information about preprocessors
  preprocess: vitePreprocess(),

  kit: {
    // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
    // If your environment is not supported, or you settled on a specific environment, switch out the adapter.
    // See https://svelte.dev/docs/kit/adapters for more information about adapters.
    adapter: adapter()
  }
};

export default config;
```

`tailwind.config.js` を修正します。

```diff:tailwind.config.js
/** @type {import('tailwindcss').Config} */
export default {
-  content: [],
+  content: ['./src/**/*.{html,js,svelte,ts}'],
  theme: {
    extend: {}
  },
  plugins: []
};
```

`src/app.css` を新たに追加します。

```css:src/app.css
@tailwind base;
@tailwind components;
@tailwind utilities;
```

`src/routes/+layout.svelte` を新たに追加します。

```svelte:src/routes/+layout.svelte
<script>
  import '../app.css';
</script>

<slot />
```

開発サーバーを再起動します。

```shell
$ bun dev --open
```

### 補足: 開発サーバーでエラー

```shell
$ bun dev --open
failed to load config from /Users/your-name/svelte-todo-app/vite.config.ts
error when starting dev server:
Error: The service was stopped
    at /Users/your-name/svelte-todo-app/node_modules/esbuild/lib/main.js:968:34
    at responseCallbacks.<computed> (/Users/your-name/svelte-todo-app/node_modules/esbuild/lib/main.js:622:9)
    at Socket.afterClose (/Users/your-name/svelte-todo-app/node_modules/esbuild/lib/main.js:613:28)
    at Socket.emit (node:events:519:35)
    at endReadableNT (node:internal/streams/readable:1696:12)
    at process.processTicksAndRejections (node:internal/process/task_queues:90:21)
error: script "dev" exited with code 1
```

私が試した時、 `node_modules` ファイルが壊れたようでこのエラーが発生しました。

```
$ rm -rf node_modules
$ bun install
$ bun dev --open
```

上記のコマンドでエラーが解消されました。

```svelte:src/routes/+page.svelte
<script lang="ts">
  import type { Todo } from '../types/todo';
  import { onMount } from 'svelte';

  let todos = $state([] as Todo[]);
  let newTodo: string = $state('');
  let isInitialized: boolean = $state(false);

  onMount(() => {
    if (typeof window !== 'undefined') {
      try {
        const savedTodos = localStorage.getItem('todos');
        if (savedTodos) {
          todos = JSON.parse(savedTodos);
        }
      } catch (e) {
        console.error('Failed to load todos from localStorage:', e);
      } finally {
        isInitialized = true;
      }
    }
  });

  $effect(() => {
    if (isInitialized && typeof window !== 'undefined') {
      try {
        localStorage.setItem('todos', JSON.stringify(todos));
      } catch (e) {
        console.error('Failed to save todos to localStorage:', e);
      }
    }
  });

  function resetInput() {
    newTodo = '';
  }

  function addTodo() {
    if (newTodo.trim()) {
      todos.push({ text: newTodo, done: false });
      resetInput();
    }
  }

  function deleteTodo(index: number) {
    todos.splice(index, 1);
  }
</script>

<main class="min-h-screen bg-gray-100 flex flex-col items-center py-10">
  <h1 class="text-4xl font-bold text-gray-800 mb-8">Todo App</h1>

  <div class="flex items-center gap-4 mb-6">
    <input
      type="text"
      bind:value={newTodo}
      placeholder="New task..."
      onkeydown={(e) => {
        if (e.key === 'Enter' && !e.isComposing) {
          addTodo();
        }
      }}
      class="w-64 p-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
    />
    <button
      onclick={addTodo}
      disabled={!newTodo.trim()}
      class="px-4 py-2 bg-blue-500 text-white font-semibold rounded-lg shadow-md hover:bg-blue-600 disabled:bg-gray-300 disabled:cursor-not-allowed"
    >
      Add
    </button>
  </div>

  <ul class="w-full max-w-md">
    {#each todos as todo, i}
      <li class="flex items-center justify-between bg-white p-4 rounded-lg shadow-sm mb-2">
        <div class="flex items-center gap-3">
          <input
            type="checkbox"
            bind:checked={todo.done}
            class="h-5 w-5 text-blue-500 focus:ring-blue-500 border-gray-300 rounded"
          />
          <span class={`text-lg ${todo.done ? 'line-through text-gray-400' : 'text-gray-700'}`}
            >{todo.text}</span
          >
        </div>
        <button onclick={() => deleteTodo(i)} class="text-red-500 font-semibold hover:underline">
          Delete
        </button>
      </li>
    {/each}
  </ul>
</main>
```

![ScreenShot 2024-12-30 10.05.04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/52879/2e515ddc-38c7-046e-7fbc-9d8943b5be97.png)

見た目が変わるとテンション上がりますね！
こんな感じにSvelteでTodoアプリを作ってみました。

GitHubに今回のTodoアプリのコードを置いてます。

https://github.com/ucan-lab/svelte-todo-app