SvelteKitでTODOアプリを作った話
SvelteKitの動的ルーティングとload関数を学んだ次のステップとして、フォームでデータを追加・削除できるTODOアプリを作りました。
この記事では +page.server.ts の actions を中心に、フォーム処理の仕組みをまとめます。
このシリーズの記事
- SvelteKitで記事一覧・詳細ページを作った話
- SvelteKitでTODOアプリを作った話(この記事)
- SvelteKitで認証・セッション管理を実装した話
- 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>
data と form の違い
$props() で受け取れる特別なプロパティが2つあります。
| プロパティ | 中身 | タイミング |
|---|---|---|
data |
load 関数の戻り値 |
ページ表示時 |
form |
actions の戻り値 |
フォーム送信後 |
fail() で返したオブジェクトがそのまま form に入ります。フォームを送信していない初期表示時は form が null になるため、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独自の強みです。