SvelteKitでTODOアプリを作った話

SvelteKitの動的ルーティングとload関数を学んだ次のステップとして、フォームでデータを追加・削除できるTODOアプリを作りました。 この記事では +page.server.tsactions を中心に、フォーム処理の仕組みをまとめます。

このシリーズの記事

  1. SvelteKitで記事一覧・詳細ページを作った話
  2. SvelteKitでTODOアプリを作った話(この記事)
  3. SvelteKitで認証・セッション管理を実装した話
  4. SvelteKitをCloudflare Pagesにデプロイした話

作ったもの

  • TODOの一覧表示(load 関数)
  • TODOの追加(actions + バリデーション)
  • TODOの削除
  • 追加後のリダイレクト

ファイル構成

src/routes/todo/
  +page.svelte          # TODOページ(UI)
  +page.server.ts       # load関数・actions(サーバー専用)
  complete/
    +page.svelte        # 追加完了ページ

+page.server.ts の load と actions

+page.ts はサーバー・クライアント両方で実行されますが、+page.server.ts はサーバーのみで実行されます。DBや秘密情報を扱う処理はここに書きます。

// src/routes/todo/+page.server.ts
import { fail, redirect } from '@sveltejs/kit';
import type { PageServerLoad, Actions } from './$types';

type Todo = {
  id: number;
  title: string;
};

let todos: Todo[] = [
  { id: 1, title: '牛乳を買う' },
  { id: 2, title: '本を読む' },
];
let nextId = 3;

// ページ表示時にサーバー側でデータを取得
export const load: PageServerLoad = () => {
  return { todos };
};

export const actions: Actions = {
  // TODOの追加
  create: async ({ request }) => {
    const data = await request.formData();
    const title = (data.get('title') as string).trim();

    if (!title) {
      return fail(400, { error: 'タイトルを入力してください' });
    }

    todos.push({ id: nextId++, title });
    redirect(303, '/todo/complete');
  },

  // TODOの削除
  delete: async ({ request }) => {
    const data = await request.formData();
    const id = Number(data.get('id'));

    todos = todos.filter(t => t.id !== id);
  },
};

actions はオブジェクトで複数のアクションを持てます。フォーム側で action="?/create"action="?/delete" のように呼ぶアクションを指定します。? は「現在のページのURL」を意味するショートハンドです。

+page.svelte

<!-- src/routes/todo/+page.svelte -->
<script lang="ts">
  import { enhance } from '$app/forms';

  let { data, form } = $props();
</script>

<h1>TODOリスト</h1>

<form method="POST" action="?/create" use:enhance>
  <input name="title" placeholder="タスクを入力" />
  <button type="submit">追加</button>
  {#if form?.error}
    <p style="color: red">{form.error}</p>
  {/if}
</form>

<ul>
  {#each data.todos as todo (todo.id)}
    <li>
      {todo.title}
      <form method="POST" action="?/delete" use:enhance>
        <input type="hidden" name="id" value={todo.id} />
        <button type="submit">削除</button>
      </form>
    </li>
  {/each}
</ul>

dataform の違い

$props() で受け取れる特別なプロパティが2つあります。

プロパティ 中身 タイミング
data load 関数の戻り値 ページ表示時
form actions の戻り値 フォーム送信後

fail() で返したオブジェクトがそのまま form に入ります。フォームを送信していない初期表示時は formnull になるため、form?.error のように ?. でアクセスします。

{#each} にキーをつける

{#each data.todos as todo (todo.id)}

(todo.id) のキーがないと、削除後にSvelteがDOMを位置で再利用するため表示がズレます。リストを扱うときはキーを必ずつけます。

use:enhance

use:enhance を付けると、フォーム送信がJavaScriptのfetchに切り替わりページのリロードがなくなります。

<form method="POST" action="?/create" use:enhance>

重要なのは method="POST" さえ書いておけばJavaScript無効の環境でもページリロード方式で動作する点です。use:enhance はそれを「より良く」するだけで、動作の前提を壊しません。この考え方を Progressive Enhancement(漸進的強化) と呼びます。

fail() でバリデーション

if (!title) {
  return fail(400, { error: 'タイトルを入力してください' });
}

fail() の第1引数はHTTPステータスコード、第2引数が form に渡るオブジェクトです。バリデーションエラーは 400 を使うのが一般的です。

redirect() とPRGパターン

redirect(303, '/todo/complete');

POST後のリダイレクトには 303 を使います。これは PRG(Post/Redirect/Get)パターン のためです。

303なし → ブラウザの戻るボタンで「フォームを再送信しますか?」→ 2重送信の危険
303あり → リダイレクト先をGETで取得 → 戻るボタンを押しても再送信は起きない

まとめ

概念 内容
+page.server.ts サーバー専用のload・actionsを書くファイル
actions フォーム送信を処理するオブジェクト
action="?/name" 呼ぶアクションを指定(? は現在のページ)
form プロパティ actionsの戻り値を受け取る
use:enhance リロードなしでフォームを送信(Progressive Enhancement)
{#each} (key) キーでDOMの再利用を正しく制御する
fail(400, {...}) バリデーションエラーをformに返す
redirect(303, ...) POST後のリダイレクト(PRGパターン)

+page.server.ts を使うことで、サーバー専用の処理をページ単位でまとめられます。APIエンドポイントを別途作らなくてもフォームの送信処理が完結するのがSvelteKit独自の強みです。


前の記事:SvelteKitで記事一覧・詳細ページを作った話

次の記事:SvelteKitで認証・セッション管理を実装した話

← トップページに戻る