Svelte

SvelteKit のフォーム操作

SvelteKit のフォームは Web 標準の機能のみで完結しています。つまり、クライアントサイドでは JavaScript を一切使用せずにサーバーにデータを送信できるのです。さらに、JavaScript を利用できる環境であるならばリッチなユーザー体験を追加できます。例えば、フォームを送信した後ページ全体の再読み込みを行わずに、フォームの送信結果を表示することができたり、バリデーションメッセージを即座に表示できたりします。

SvelteKit のフォームは Web 標準の機能のみで完結しています。つまり、クライアントサイドでは JavaScript を一切使用せずにサーバーにデータを送信できるのです。

さらに、JavaScript を利用できる環境であるならばリッチなユーザー体験を追加できます。例えば、フォームを送信した後ページ全体の再読み込みを行わずに、フォームの送信結果を表示できたり、バリデーションメッセージを即座に表示できたりします。

このように古いブラウザや機能の限られた端末のユーザーをサポートしつつ 、モダンなブラウザのユーザーにはリッチなユーザー体験を提供することはプログレッシブエンハンスメントと呼ばれています。 SveltKit のフォームは簡単にプログレッシブエンハンスメントを実現できるように設計されています。

シンプルなフォーム

まずははじめに一番シンプルなフォームを作成してみましょう。routes ディレクトリ配下の +page.svelte という名前のファイルはクライアントのページを担当します。例えば、src/routes/todo/add/+page.svelte というファイルは /todo/add という URL に対応するページをレンダリングします。

src/routes/todo/add/+page.svelte
<h1>Add Todo</h1>
 
<form method="post">
	<label for="title">Title</label>
	<input type="text" id="title" name="title" placeholder="Title" />
 
	<label for="description">Description</label>
	<textarea id="description" name="description" placeholder="Description" />
 
	<button type="submit">Add</button>
</form>

中身は純粋な HTML のみで構成されたフォームです。Svelte に慣れていない方にとっても見慣れたフォームではないでしょうか。

method="post" という属性が付与されているので、このフォームは POST メソッドでデータを送信します。<input><textarea> には name 属性が付与されています。これはフォームのデータを送信する際に、どのようなキーでデータを送信するかを指定するためのものです。

actions でサーバー側の処理を記述する

続いてフォームが送信された時に処理を行うサーバー側の実装を行います。サーバー側の処理を記述する場合には +page.server.ts という名前のファイルを作成します。先程作成した +page.svelte と同じディレクトリに +page.server.ts を作成すると、+page.svelte と同じ URL に対応するサーバー側の処理を記述できます。

フォームの送信結果をサーバー側で受け取る場合には actions という名前のオブジェクトを export します。実際の処理は default という名前の関数内に記述します。この default とはフォームに名前が付与されていない場合に呼び出される関数の名前です。default 以外の名前のプロパティとすることで、フォームに名前を付与でき、同じ URL に対して複数のフォームを作成できます。

src/routes/todo/add/+page.server.ts
import { fail, type Actions } from '@sveltejs/kit';
 
/** DB に保存するときの遅延を擬似的に再現 */
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
 
/*+ 偽の DB に保存する関数 */
const createTodo = async (todo: { title: string; description: string }) => {
	console.log('createTodo', todo);
	await sleep(1500);
	return { id: crypto.randomUUID(), done: false, ...todo };
};
 
export const actions = {
	default: async ({ request }) => {
		// リクエストからフォームデータを取得
		const data = await request.formData();
 
		const title = data.get('title');
		const description = data.get('description');
 
		// 簡易的なバリデーション
		if (!title) {
			// fail は SvelteKit のヘルパー関数
			// 失敗を表すレスポンスを返す
			return fail(400, {
				errors: {
					title: 'required'
				},
				title: title?.toString() ?? '',
				description: description?.toString() ?? ''
			});
		}
 
    await createTodo({
      title: title.toString(),
      description: description?.toString() ?? ''
    });
 
    // 成功を表すレスポンスを返す
    return {
      success: true
    };
	}
} satisfies Actions;

ここでは擬似的に作成した Todo を DB に保存する処理を実行しています。actions オブジェクトのそれぞれのプロパティは RequestEvent オブジェクトを引数に受け取ります。プロパティの 1 つとして Request オブジェクトを受け取るので、await request.formData() とすることでフォームのデータを取得できます。

data.get('title') でフォームの name 属性が title である要素の値を取得できます。

export const actions = {
	default: async ({ request }) => {
		// リクエストからフォームデータを取得
		const data = await request.formData();
 
		const title = data.get('title');
		const description = data.get('description');

もし title が未入力であった場合、バリデーションエラーを返すようにしています。SvelteKit の fail 関数を呼び出すことで、リクエストを失敗したことを表すレスポンスを返します。このとき、入力された値をそのまま返すことで、フォームの値を保持したままエラーを表示できます。

if (!title) {
  return fail(400, {
    errors: {
      title: 'required'
    },
    title: title?.toString() ?? '',
    description: description?.toString() ?? ''
  });
}

フォームの値が正しく保存できた場合には、{ success: true } というレスポンスを返しています。

await createTodo({
  title: title.toString(),
  description: description?.toString() ?? ''
});
 
// 成功を表すレスポンスを返す
return {
  success: true
};

このとき返すオブジェクトは JSON としてシリアライズ可能である必要があります。

actios の返すレスポンスを処理する

actionsdefault 関数内で返されたオブジェクトは、+page.svelte 内で form という名前の変数を export することで受け取ることができます。このとき、form の型定義は ./$types という SvelteKit が作成する特別なファイルから import します。

./$types の型はサーバー側の処理を記述したときの開発サーバーを起動しているか、svelte-kit sync を実行した際に生成されます。

src/routes/todo/add/+page.svelte
<script lang="ts">
	import type { ActionData } from './$types';
 
	export let form: ActionData;
</script>

この form 変数の値を使用して、以下の処理を追加します。

  • フォームの送信に成功した場合には、Todo added! というメッセージを表示する
  • フォームの送信に失敗した場合には、Title is required というメッセージを表示する
  • フォームの送信に成功した場合には、前回のフォームの値を保持する
src/routes/todo/add/+page.svelte
+ {#if form?.success}
+ 	<p style:color="green">Todo added!</p>
+ {/if}
 
  <h1>Add Todo</h1>
 
  <form method="post">
  	<label for="title">Title</label>
-   <input type="text" id="title" name="title" placeholder="Title" />
+ 	<input type="text" id="title" name="title" placeholder="Title" value={form?.title ?? ''} />
+ 	<p style:color="red">
+ 		{#if form?.errors?.title === 'required'}
+ 			Title is required
+ 		{/if}
+ 	</p>
 
  	<label for="description">Description</label>
-  <textarea id="description" name="description" placeholder="Description" />
+ 	<textarea
+ 		id="description"
+ 		name="description"
+ 		placeholder="Description"
+ 		value={form?.description ?? ''}
+ 	/>
 
  	<button type="submit">Add</button>
  </form>

次のように、エラーメッセージや成功メッセージが表示されることを確認できます。

フォームの送信に成功し、Todo added! というメッセージが表示されている

フォームの送信に失敗し、Title is required というメッセージが表示されている

プログレッシブエンハンスメントを実装する

今まで実装したフォームはクライアントサイドの JavaScript を一切使用せずに実装してきました。ブラウザの JavaScript を無効にした状態で試してみると、実際に変わりなく動作することが確認できます。

冒頭でも説明したとおり、プログレッシブエンハンスメントとして JavaScript を使用できる環境ではリッチなユーザー体験を追加できます。フォームにプログレッシブエンハンスメントを追加する方法は簡単です。<form>use:enhanec アクションを追加することです。

src/routes/todo/add/+page.svelte
  <script lang="ts">
+ 	import { enhance } from '$app/forms';
  	import type { ActionData } from './$types';
 
  	export let form: ActionData;
  </script>
 
  {#if form?.success}
  	<p style:color="green">Todo added!</p>
  {/if}
 
  <h1>Add Todo</h1>
 
- <form method="post">
+  <form method="post" use:enhance>
  	<label for="title">Title</label>
  	<input type="text" id="title" name="title" placeholder="Title" value={form?.title ?? ''} />

この use: は Svelte の アクション と呼ばれるディレクティブです。 use: ディレクティブには関数を渡し、要素が作成された時に関数が呼び出されます。destory メソッドを持つオブジェクトを返すと、要素がアンマウントされるときに destory メソッドが呼び出されます。update メソッドを持つオブジェクトを返すと、要素が更新されるときに update メソッドが呼び出されます。

<script>
	function foo(node) {
		// the node has been mounted in the DOM
 
		return {
			destroy() {
				// the node has been removed from the DOM
			},
      update(bar) {
				// the value of `bar` has changed
			},
		};
	}
</script>
 
<div use:foo></div>

ここで <form> に渡している enhance 関数は SvelteKit の $app/forms モジュールからインポートしています。use:enhance を使用している場合、フォームを送信してページを再読み込みする代わりに、fetch 関数を用いてフォームデータを送信するようになります。このとき、ブラウザネイティブの動作が JavaScript でエミュレートされます。

  • actions が送信元のページと同じ場所にある場合に限り、成功レスポンスまたは不正なレスポンスにおじて form プロパティ、$page.form$page.status の値が更新される
  • 成功レスポンスの場合 <form> をリセットし、invalidateAll でデータを最新化する
  • actios の中で redirect を呼び出した場合、goto でナビゲーションする
  • エラーが発生した場合、最も近くの +error.svelte をレンダリングする
  • 適切な要素にフォーカスをリセットする

エンハンスメントのカスタマイズ

use:enhance に引数を渡すことで、エンハンスメントの動作をカスタマイズできます。引数には SubmitFunction 関数を受け取ります。この関数は form が送信される前に呼び出され、フォームの送信が完了したらコールバック関数を呼び出します。

エンハンスメントのカスタマイズにより、例えばロード中の UI を表示したりなどの処理を行えます。

src/routes/todo/add/+page.svelte
  <script lang="ts">
  	import { enhance } from '$app/forms';
  	import type { ActionData } from './$types';
 
  	export let form: ActionData;
 
+ 	let loading = false;
  </script>
 
  {#if form?.success}
  	<p style:color="green">Todo added!</p>
  {/if}
 
+ {#if loading}
+ 	<p>Loading...</p>
+ {/if}
 
  <h1>Add Todo</h1>
 
  <form
  	method="post"
-   use:enhance
+ 	use:enhance={({ form, data, action, cancel, submitter }) => {
+ 		loading = true;
+
+ 		return async ({ result, update }) => {
+       await update();
+ 			loading = false;
+ 		};
  	}}
  >

名前付きフォーム

actions オブジェクトのプロパティとして単一の default プロパティを返す代わりに、複数の名前付き action を定義できます。

例えば、Todo の一覧ページで Todo の完了と削除をどちらも行えるフォームを考えてみましょう。この場合、actions オブジェクトに completedelete の 2 つのプロパティを追加します。

パスパラメーターとして id を受け取るため、src/routes/todo/[id] というディレクトリに +page.server.ts ファイルを配置します。

src/routes/todo/[id]+page.server.svelte
import { type Actions, type Load, fail } from '@sveltejs/kit';
 
/**
 * ロード関数はレンダリング前にデータを取得する関数
 * 通常は DB からデータを取得するはず
 */
export const load: Load = () => {
	return {
		todos: [
			{
				id: '1',
				title: 'title1',
				description: 'description1',
				done: false
			},
			{
				id: '2',
				title: 'title2',
				description: 'description2',
				done: false
			},
			{
				id: '3',
				title: 'title3',
				description: 'description3'
			}
		]
	};
};
 
export const actions = {
	/** Todo を完了する action */
	complete: async ({ params }) => {
		const { id } = params;
 
		if (!id) {
			return fail(400, {
				errors: {
					id: 'required'
				}
			});
		}
 
		// DB を更新する処理...
 
		return {
			update: true
		};
	},
	delete: async ({ params }) => {
		const { id } = params;
 
		if (!id) {
			return fail(400, {
				errors: {
					id: 'required'
				}
			});
		}
 
		// DB を更新する処理...
 
		return {
			delete: true
		};
	}
} satisfies Actions;

それぞれのアクションでは params から id を受け取り、更新と削除を実行します。

+page.svelte 側ではサブミットボタンの formaction 属性によってどのアクションを呼び出すか指定しています。名前付きアクションを呼び出すにはクエリパラメーターに / をプレフィックスにしたアクション名です。complete アクションを呼び出すなら formaction="?/complete のようにします。

一覧ページはフォームの処理を行う /tood/id/ というパスと異なる /todo というパスであるため、フォームアクション名を指定したクエリパラメータの他にフォームの指定先のパスを指定する必要があります。formaction の実際の値は /todo/{todo.id}?/complete のようになります。

src/routes/todo/+page.svelte
<script lang="ts">
	import { enhance } from '$app/forms';
	import type { PageData } from './$types';
 
	export let data: PageData;
</script>
 
<h1>Todo</h1>
 
<form method="post" use:enhance>
	<ul>
		{#each data.todos as todo}
			<li>
				<span>{todo.title}</span>
				<span>{todo.done ? '' : ''}</span>
				<button
					type="submit"
					formaction="/todo/{todo.id}?/complete"
					data-id={todo.id}
					value={todo.id}
				>
					{todo.done ? 'Mark as incomplete' : 'Mark as complete'}
				</button>
				<button type="submit" formaction="/todo/{todo.id}?/delete" data-id={todo.id}>
					Delete
				</button>
			</li>
		{/each}
	</ul>
</form>

なお、名前付きアクションを使用している場合には default action を定義できません。

スナップショット

通常、フォームの値を入力した後にページを更新したりブラウザバックしたりすると、フォームの値が消えてしまいます。誤ってページから離脱してしまった場合、はじめからフォームの値を入力しなおす必要があるのはユーザー体験上好ましくないでしょう。

SvelteKit では DOM の状態をスナップショットとして保存でき、ユーザーが戻ってきた時に復元できます。スナップショットを利用するためには、capture メソッドと restore メソッドを持つ snapshot オブジェクトを +page.svelte 上で export します。

ユーザーがページから離脱する際には capture メソッドが呼ばれます。このメソッドの戻り値がスナップショットとして保存されます。ユーザーがページに戻ってきた際には restore メソッドが呼ばれ、引数として capture メソッドで返した値が渡されます。

src/routes/todo/add/+page.svelte
<script lang="ts">
	import type { ActionData, Snapshot } from './$types';
 
	export let form: ActionData;
 
	let title = '';
	let description = '';
 
	export const snapshot = {
		capture: () => ({
			title,
			description
		}),
		restore: (data) => {
			title = data.title;
			description = data.description;
		}
	} satisfies Snapshot;
</script>
 
	<label for="title">Title</label>
	<input
		type="text"
		id="title"
		name="title"
		placeholder="Title"
		value={form?.title ?? title}
		on:change={(e) => {
			if (e.target instanceof HTMLInputElement) {
				title = e.target.value;
			}
		}}
	/>
 
  	<label for="description">Description</label>
	<textarea
		id="description"
		name="description"
		placeholder="Description"
		value={form?.description ?? description}
		on:change={(e) => {
			if (e.target instanceof HTMLTextAreaElement) {
				description = e.target.value;
			}
		}}
	/>

データは sessionStorage に保存されます。そのため、JSON としてシリアライズ可能である必要があります。

まとめ

  • SvelteKit のフォームは Web 標準の機能のみで実装できる
  • use:enhance ディレクティブを使うことでリッチなユーザー体験を提供できる

Contributors

> GitHub で修正を提案する
この記事をシェアする
はてなブックマークに追加

関連記事