フォーム処理とActions

SvelteKitのActionsは、プログレッシブエンハンスメントに対応した強力なフォーム処理システムです。JavaScriptが無効でも動作し、有効時にはシームレスな体験を提供します。このガイドでは、基本的なフォーム処理から高度なバリデーション、ファイルアップロードまで、実践的なTypeScriptコード例で完全解説します。

プログレッシブエンハンスメント って何?

Progressive Enhancement(プログレッシブエンハンスメント)は、JavaScript無効時でも基本機能が動作し、有効時にはよりリッチな体験を提供する開発アプローチです。

詳細は SvelteKit概要ページのプログレッシブエンハンスメント をご覧ください。

Actions の基本概念

SvelteKitのActionsは、サーバーサイドでフォーム送信を処理する仕組みです。従来のWebアプリケーションと同じようにHTMLフォームを使いながら、モダンなユーザー体験を提供できます。

重要な理解

Actions自体はサーバーサイドのみで動作します。JavaScript無効時も有効時も、フォーム処理のロジックは常にサーバー側のActionsが実行します。use:enhanceは、クライアント側でのUX改善のみを担当し、処理自体には関与しません。

SvelteKit Actionsの役割

Actionsは、サーバーサイドでフォーム処理を担当します。

  1. 基本動作(JavaScript無効時)

    • ブラウザの標準的なフォーム送信(POSTリクエスト)
    • サーバー側のActionsがフォームデータを処理
    • 処理後、ページ全体をリロードして結果を表示
    • これは純粋なHTMLとサーバーサイド処理で動作
  2. 強化動作(JavaScript有効時 + use:enhance))

    • use:enhanceディレクティブがフォーム送信をインターセプト
    • AJAXでフォームデータをサーバーに送信
    • 同じActionsがサーバー側で処理
    • ページリロードなしで結果を反映
    • ローディング状態やアニメーションを追加可能

基本的なAction実装

最もシンプルなActionの実装例です。+page.server.tsファイルでactionsオブジェクトをエクスポートすることで、フォーム送信を処理できます。

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

export const actions = {
  default: async ({ request }) => {
    const data = await request.formData();
    const name = data.get('name');
    const email = data.get('email');
    const message = data.get('message');
    
    // バリデーション
    if (!name || !email || !message) {
      return fail(400, {
        error: '全ての項目を入力してください',
        name,
        email,
        message
      });
    }
    
    // メール送信処理など
    await sendEmail({ name, email, message });
    
    return {
      success: true
    };
  }
} satisfies Actions;
typescript
<!-- src/routes/contact/+page.svelte -->
<script lang="ts">
  import { enhance } from '$app/forms';
  import type { ActionData } from './$types';
  
  let { form }: { form: ActionData } = $props();
</script>

<form method="POST" use:enhance>
  {#if form?.error}
    <p class="error">{form.error}</p>
  {/if}
  
  {#if form?.success}
    <p class="success">送信完了しました!</p>
  {/if}
  
  <input 
    name="name" 
    placeholder="お名前"
    value={form?.name ?? ''}
    required
  />
  
  <input 
    name="email" 
    type="email"
    placeholder="メールアドレス"
    value={form?.email ?? ''}
    required
  />
  
  <textarea 
    name="message"
    placeholder="メッセージ"
    value={form?.message ?? ''}
    required
  />
  
  <button type="submit">送信</button>
</form>
svelte

Actionsの実行タイミングとフロー

Actionsはサーバーサイドで実行され、以下のような流れで処理が進みます。理解することで、より効果的なフォーム処理の実装が可能になります。

実行フローの全体像

ダイアグラムを読み込み中...

上図は、フォーム送信時の処理フローを示しています。

JavaScript無効時の流れ(上段)

  • ブラウザが標準のHTMLフォーム送信を行い、POSTリクエストとして全画面遷移を伴います
  • サーバー側でAction関数が実行され、その後Load関数が実行されます
  • 最終的にHTML全体が返却され、ページ全体が再描画されます

JavaScript有効時の流れ(下段)

  • use:enhanceがフォーム送信をインターセプトし、fetch APIを使用して非同期送信します
  • サーバー側の処理は同じですが、レスポンスはJSONとして返却されます
  • ブラウザ側で差分更新を行い、必要な部分だけを再描画します

詳細な実行タイミング

1. リクエスト受信からAction実行まで

ダイアグラムを読み込み中...

POSTリクエストを受信してからActionが実行されるまでの詳細な判定フローです。

処理の流れ
  1. Action定義の確認 - 該当ページにActionsが定義されているか確認します
  2. FormData解析 - リクエストボディからフォームデータを解析します
  3. Named Action判定 - URLに?/actionNameが含まれているか確認します
  4. Action実行 - 該当するAction関数またはdefaultアクションを実行します
  5. 結果処理 - リダイレクト、エラー、成功のいずれかを処理します

2. Actionとload関数の実行順序

Actions実行後のデータフローを理解することが重要です。

// +page.server.ts
import type { Actions, PageServerLoad } from './$types';

export const load: PageServerLoad = async ({ locals }) => {
  console.log('1. Load関数が実行されます');

  // 初期データの取得
  const posts = await getPosts();

  return {
    posts,
    timestamp: Date.now() // いつ実行されたか確認用
  };
};

export const actions = {
  create: async ({ request }) => {
    console.log('2. Action(create)が実行されます');

    const data = await request.formData();
    const title = data.get('title');

    // 新しい投稿を作成
    await createPost({ title });

    // 成功を返す
    // この後、自動的にload関数が再実行される
    return { success: true };
  }
} satisfies Actions;
typescript

実行順序

  1. 初回ページ読み込み: load → ページレンダリング
  2. フォーム送信時: actionload → ページ再レンダリング
  3. use:enhance使使用時: action → 必要に応じてload → 差分更新

リダイレクト時の動作

Actionからリダイレクトする場合の処理フローを理解することで、ログイン後の画面遷移などを適切に実装できます。

import { redirect } from '@sveltejs/kit';

export const actions = {
  login: async ({ request, cookies }) => {
    // ログイン処理
    const { email, password } = await getFormData(request);
    const user = await authenticate(email, password);

    if (user) {
      // セッション設定
      cookies.set('session', user.sessionId, { path: '/' });

      // リダイレクト(303 See Other)
      // この時点でaction処理は終了
      throw redirect(303, '/dashboard');
      // ↓ これ以降のコードは実行されない
    }

    return fail(401, { message: 'Invalid credentials' });
  }
} satisfies Actions;
typescript
ダイアグラムを読み込み中...

リダイレクトの仕組み

  • 成功時 - redirect()をthrowすることで303ステータスを返却し、ブラウザを新しいページへ遷移させます
  • エラー時 - エラーデータを返却し、同じページで再描画してエラーメッセージを表示します
  • 重要 - redirectはthrowで実行するため、それ以降のコードは実行されません

エラー処理のタイミング

export const actions = {
  default: async ({ request }) => {
    try {
      const data = await request.formData();

      // バリデーションエラー(400番台)
      if (!data.get('email')) {
        // failヘルパーを使用
        return fail(400, {
          missing: true,
          message: 'Email is required'
        });
      }

      // 処理実行
      await processData(data);

      return { success: true };

    } catch (error) {
      // サーバーエラー(500番台)
      // この場合、エラーページが表示される
      throw error;
    }
  }
} satisfies Actions;
typescript

use:enhance のライフサイクル

ダイアグラムを読み込み中...

use:enhanceの処理フローを理解することで、柔軟なカスタマイズが可能になります。

各ステージの説明

  1. 送信前処理 - バリデーションやローディング表示を開始
  2. キャンセル判定 - 条件によって送信を中止可能
  3. サーバー処理 - Actionが実行され結果を返却
  4. result判定 - success/failure/redirectを判定して適切に処理
  5. DOM更新 - update関数でページを更新
use:enhance={({ form, data, action, cancel }) => {
  // ステップ1: 送信前処理(同期的に実行)
  console.log('フォーム送信開始');

  // バリデーションやローディング表示
  const email = data.get('email')?.toString();
  if (!email?.includes('@')) {
    alert('メールアドレスが不正です');
    cancel(); // ステップ2: キャンセル判定
    return;
  }

  // ローディング開始
  submitting = true;

  // ステップ3-7: 非同期処理を返す
  return async ({ result, update }) => {
    // ステップ8: レスポンス受信後の処理
    console.log('サーバーからのレスポンス:', result);

    // ステップ9-10: result.typeの判定と処理
    if (result.type === 'success') {
      // 成功時のカスタム処理
      console.log('処理成功');
      form.reset(); // フォームをリセット
    } else if (result.type === 'failure') {
      // 失敗時のカスタム処理
      console.log('エラー:', result.data);
      errors = result.data?.errors || {};
    } else if (result.type === 'redirect') {
      // リダイレクト時の処理
      console.log('リダイレクト先:', result.location);
      // 通常は自動的に処理される
    }

    // ステップ11: DOM更新(update関数)
    await update();

    // ステップ12: DOM更新後の処理
    submitting = false;
    console.log('すべての処理完了');
  };
}}
typescript

パフォーマンスの考慮事項

並列処理の活用

export const load: PageServerLoad = async ({ parent }) => {
  // 親のloadを待つ必要がある場合
  const parentData = await parent();

  // 並列でデータ取得
  const [posts, categories, tags] = await Promise.all([
    getPosts(),
    getCategories(),
    getTags()
  ]);

  return {
    ...parentData,
    posts,
    categories,
    tags
  };
};
typescript

invalidateの最適化

<script lang="ts">
  import { invalidate, invalidateAll } from '$app/navigation';

  async function handleSubmit() {
    // 特定のloadだけ再実行
    await invalidate('app:posts');

    // または全てのloadを再実行
    // await invalidateAll();
  }
</script>
svelte

複数のActions

一つのページで複数の異なるアクションを処理する必要がある場合、Named Actions(名前付きアクション)を使用します。これにより、更新、削除、公開など、異なる操作を同じページで実装できます。

Named Actions の実行フロー

ダイアグラムを読み込み中...

上図は、同一ページで複数のActionを処理する際のフローを示しています。

処理のポイント

  • URLパラメータ - ?/update?/deleteのようにAction名をURLに付与します
  • 適切なAction実行 - URLパラメータに基づいて該当するAction関数を実行します
  • 異なる処理 - 更新はその場で結果表示、削除はリダイレクトなど、柔軟な処理が可能です

Named Actions の実装

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

export const actions = {
  update: async ({ request, params, locals }) => {
    const data = await request.formData();
    const title = data.get('title')?.toString();
    const content = data.get('content')?.toString();
    
    if (!title || !content) {
      return fail(400, {
        error: 'タイトルと内容は必須です'
      });
    }
    
    await updatePost(params.id, { title, content });
    
    return { success: true };
  },
  
  delete: async ({ params, locals }) => {
    const user = await locals.getUser();
    
    if (!user?.isAdmin) {
      return fail(403, {
        error: '権限がありません'
      });
    }
    
    await deletePost(params.id);
    throw redirect(303, '/posts');
  },
  
  publish: async ({ params }) => {
    await publishPost(params.id);
    return { published: true };
  }
} satisfies Actions;
typescript
<!-- src/routes/posts/[id]/+page.svelte -->
<script lang="ts">
  import { enhance } from '$app/forms';
  import type { PageData, ActionData } from './$types';
  
  let { data, form }: { data: PageData; form: ActionData } = $props();
</script>

<!-- 更新フォーム -->
<form method="POST" action="?/update" use:enhance>
  <input name="title" value={data.post.title} />
  <textarea name="content">{data.post.content}</textarea>
  <button type="submit">更新</button>
</form>

<!-- 削除フォーム -->
<form method="POST" action="?/delete" use:enhance>
  <button type="submit" class="danger">削除</button>
</form>

<!-- 公開フォーム -->
<form method="POST" action="?/publish" use:enhance>
  <button type="submit" class="primary">公開</button>
</form>
svelte

高度なバリデーション

フォームデータの検証は、アプリケーションのセキュリティと信頼性にとって極めて重要です。ここでは、型安全性を保ちながら強力なバリデーションを実装する方法を紹介します。

バリデーションフロー

ダイアグラムを読み込み中...

バリデーションは段階的に実施し、各ステップでエラーを検出します。

バリデーションの流れ

  1. 必須項目チェック - 必要なフィールドが存在するか確認
  2. 型バリデーション - メールアドレス、数値、日付などの形式チェック
  3. ビジネスルール - 重複チェック、権限確認など
  4. エラーハンドリング - エラー時は入力値を保持して再表示

Zodを使った型安全なバリデーション

Zodは、TypeScriptファーストのスキーマ検証ライブラリです。スキーマ定義から型を自動生成でき、実行時とコンパイル時の両方で型安全性を保証します。

// src/routes/register/+page.server.ts
import type { Actions } from './$types';
import { fail } from '@sveltejs/kit';
import { z } from 'zod';
import { superValidate, message } from 'sveltekit-superforms/server';

const schema = z.object({
  username: z.string()
    .min(3, 'ユーザー名は3文字以上')
    .max(20, 'ユーザー名は20文字以下')
    .regex(/^[a-zA-Z0-9_]+$/, '英数字とアンダースコアのみ使用可能'),
  
  email: z.string()
    .email('有効なメールアドレスを入力してください'),
  
  password: z.string()
    .min(8, 'パスワードは8文字以上')
    .regex(/[A-Z]/, '大文字を含めてください')
    .regex(/[a-z]/, '小文字を含めてください')
    .regex(/[0-9]/, '数字を含めてください'),
  
  confirmPassword: z.string(),
  
  terms: z.boolean()
    .refine((val) => val === true, '利用規約に同意してください')
}).refine((data) => data.password === data.confirmPassword, {
  message: "パスワードが一致しません",
  path: ["confirmPassword"]
});

export const actions = {
  default: async ({ request }) => {
    const form = await superValidate(request, schema);
    
    if (!form.valid) {
      return fail(400, { form });
    }
    
    // ユーザー登録処理
    const { username, email, password } = form.data;
    
    try {
      await createUser({ username, email, password });
      throw redirect(303, '/welcome');
    } catch (error) {
      return message(form, 'ユーザー登録に失敗しました');
    }
  }
} satisfies Actions;
typescript

カスタムバリデーション関数

Zodを使わずに、独自のバリデーションロジックを実装することも可能です。以下は、汎用的なバリデーション関数の実装例です。

// src/lib/validators.ts
export interface ValidationError {
  field: string;
  message: string;
}

export function validateForm<T extends Record<string, any>>(
  data: FormData,
  validators: Record<keyof T, (value: any) => string | null>
): { valid: boolean; errors: ValidationError[]; values: Partial<T> } {
  const errors: ValidationError[] = [];
  const values: Partial<T> = {};
  
  for (const [field, validator] of Object.entries(validators)) {
    const value = data.get(field);
    const error = validator(value);
    
    if (error) {
      errors.push({ field, message: error });
    } else {
      values[field as keyof T] = value as T[keyof T];
    }
  }
  
  return {
    valid: errors.length === 0,
    errors,
    values
  };
}
typescript

ファイルアップロード

SvelteKitでファイルアップロードを実装する方法を解説します。セキュリティを考慮しながら、画像やドキュメントなどのファイルを安全に処理します。

ファイルアップロードのフロー

ダイアグラムを読み込み中...

ファイルアップロードの全体的な流れを示しています。

アップロードの特徴

  • プログレス表示 - XMLHttpRequestを使用してリアルタイムで進捗を表示
  • サーバー検証 - ファイルサイズとタイプをチェック
  • ストレージ保存 - ローカルまたはクラウドストレージへ保存
  • プレビュー表示 - アップロード後に画像を即座に表示

基本的なファイルアップロード

FormDataを使用してファイルを受け取り、サーバーに保存する基本的な実装です。

// src/routes/upload/+page.server.ts
import type { Actions } from './$types';
import { fail } from '@sveltejs/kit';
import { writeFile } from 'fs/promises';
import path from 'path';

export const actions = {
  default: async ({ request }) => {
    const data = await request.formData();
    const file = data.get('file') as File;
    
    if (!file) {
      return fail(400, { error: 'ファイルを選択してください' });
    }
    
    // ファイルサイズチェック(5MB)
    if (file.size > 5 * 1024 * 1024) {
      return fail(400, { error: 'ファイルサイズは5MB以下にしてください' });
    }
    
    // ファイルタイプチェック
    const allowedTypes = ['image/jpeg', 'image/png', 'image/gif'];
    if (!allowedTypes.includes(file.type)) {
      return fail(400, { error: '画像ファイルのみアップロード可能です' });
    }
    
    // ファイル保存
    const fileName = `${Date.now()}-${file.name}`;
    const filePath = path.join('uploads', fileName);
    const arrayBuffer = await file.arrayBuffer();
    const buffer = Buffer.from(arrayBuffer);
    
    await writeFile(filePath, buffer);
    
    return {
      success: true,
      fileName,
      url: `/uploads/${fileName}`
    };
  }
} satisfies Actions;
typescript

プログレス表示付きアップロード

大きなファイルのアップロード時に進捗を表示する方法を2つ紹介します。

方法1: Fetch API + Streams API(モダンな方法)

<!-- src/routes/upload/+page.svelte -->
<script lang="ts">
  import type { ActionData } from './$types';

  let { form }: { form: ActionData } = $props();

  let uploading = $state(false);
  let progress = $state(0);
  let fileInput: HTMLInputElement;

  async function handleUpload() {
    const file = fileInput.files?.[0];
    if (!file) return;

    uploading = true;
    progress = 0;

    const formData = new FormData();
    formData.append('file', file);

    try {
      // ReadableStreamを使用してアップロード進捗を追跡
      const response = await fetch('/upload', {
        method: 'POST',
        body: formData,
        // 注意: Fetch APIでのアップロード進捗は限定的
        // ダウンロード進捗の方が簡単に取得可能
      });

      if (response.ok) {
        const data = await response.json();
        // 成功処理
        form = data;
      }
    } catch (error) {
      console.error('アップロードエラー:', error);
    } finally {
      uploading = false;
    }
  }

  // 代替案: チャンク分割アップロード(より正確な進捗)
  async function handleChunkedUpload() {
    const file = fileInput.files?.[0];
    if (!file) return;

    const chunkSize = 1024 * 1024; // 1MB
    const totalChunks = Math.ceil(file.size / chunkSize);

    uploading = true;

    for (let i = 0; i < totalChunks; i++) {
      const start = i * chunkSize;
      const end = Math.min(start + chunkSize, file.size);
      const chunk = file.slice(start, end);

      const formData = new FormData();
      formData.append('chunk', chunk);
      formData.append('index', i.toString());
      formData.append('total', totalChunks.toString());
      formData.append('filename', file.name);

      await fetch('/upload/chunk', {
        method: 'POST',
        body: formData
      });

      progress = Math.round(((i + 1) / totalChunks) * 100);
    }

    uploading = false;
  }
</script>

<button onclick={handleChunkedUpload}>
  チャンク分割でアップロード(推奨)
</button>
svelte

方法2: XMLHttpRequest(レガシーだが確実)

<script lang="ts">
  // XMLHttpRequestはアップロード進捗を確実に取得できる
  // 古い方法だが、プログレス表示には最適

  async function handleUploadXHR() {
    const file = fileInput.files?.[0];
    if (!file) return;

    uploading = true;
    progress = 0;

    const formData = new FormData();
    formData.append('file', file);

    const xhr = new XMLHttpRequest();

    // プログレスイベント(これがFetch APIでは難しい)
    xhr.upload.addEventListener('progress', (e) => {
      if (e.lengthComputable) {
        progress = Math.round((e.loaded / e.total) * 100);
      }
    });

    // 完了イベント
    xhr.addEventListener('load', () => {
      uploading = false;
      if (xhr.status === 200) {
        const response = JSON.parse(xhr.responseText);
        form = response;
      }
    });

    xhr.open('POST', '/upload');
    xhr.send(formData);
  }
</script>

<form onsubmit={(e) => { e.preventDefault(); handleUpload(); }}
  <input 
    type="file" 
    bind:this={fileInput}
    accept="image/*"
    disabled={uploading}
  />
  
  {#if uploading}
    <div class="progress-bar">
      <div 
        class="progress-fill" 
        style="width: {progress}%"
      />
    </div>
    <p>{progress}% アップロード中...</p>
  {/if}
  
  <button type="submit" disabled={uploading}>
    アップロード
  </button>
</form>

{#if form?.success}
  <img src={form.url} alt="Uploaded" />
{/if}
svelte

CSRFプロテクション

CSRF(Cross-Site Request Forgery)攻撃から保護するため、トークンベースの検証を実装します。これにより、悪意のあるサイトからの不正なフォーム送信を防ぐことができます。

トークンベースのCSRF対策

Hooksを使用してCSRFトークンを生成し、フォーム送信時に検証する実装です。

resolve関数について

resolve関数は、SvelteKitのHandleフック内で使用される関数で、SvelteKitアプリケーション内部のルーティングとリクエスト処理を実行する役割を持ちます。

// Handleフックの基本構造
export const handle: Handle = async ({ event, resolve }) => {
  // リクエスト前の処理(認証、ヘッダー追加など)

  // SvelteKit内部のルーティング処理を実行
  const response = await resolve(event);

  // レスポンス後の処理(ヘッダー追加、ログなど)

  return response;
};
typescript

resolve(event)を呼ぶことで、

  • SvelteKitのアプリケーション内のルートsrc/routes配下)へのルーティング
  • 該当ページのLoad関数やActionsの実行
  • ページのレンダリングとレスポンス生成

が行われます。外部サイトへのリクエストではなく、SvelteKitアプリケーション内部の処理を指します。

Hooksの詳細については、今後作成予定の Hooksページ で解説予定です。

// src/hooks.server.ts
import type { Handle } from '@sveltejs/kit';
import { randomBytes } from 'crypto';

export const handle: Handle = async ({ event, resolve }) => {
  // CSRFトークンの生成(リクエスト前の処理)
  let token = event.cookies.get('csrf');

  if (!token) {
    token = randomBytes(32).toString('hex');
    event.cookies.set('csrf', token, {
      httpOnly: true,
      sameSite: 'strict',
      secure: true,
      path: '/'
    });
  }

  event.locals.csrf = token;

  // 通常のリクエスト処理を実行
  return resolve(event);
};
typescript
// src/routes/secure/+page.server.ts
import type { Actions } from './$types';
import { fail } from '@sveltejs/kit';

export const actions = {
  default: async ({ request, locals }) => {
    const data = await request.formData();
    const csrfToken = data.get('csrf_token');
    
    // CSRFトークンの検証
    if (csrfToken !== locals.csrf) {
      return fail(403, {
        error: '不正なリクエストです'
      });
    }
    
    // 安全な処理を実行
    // ...
  }
} satisfies Actions;
typescript

リアルタイムバリデーション

ユーザーが入力中にリアルタイムでバリデーションを行うことで、エラーを早期に発見し、より良いユーザー体験を提供できます。

デバウンス付きリアルタイム検証

デバウンス処理により、入力が完了してから一定時間後にバリデーションを実行します。これにより、サーバーへの不要なリクエストを削減できます。

<script lang="ts">
  let email = $state('');
  let emailError = $state('');
  let checking = $state(false);
  let timeoutId: number;
  
  async function checkEmail(value: string) {
    // 基本的なバリデーション
    if (!value) {
      emailError = '';
      return;
    }
    
    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
      emailError = '有効なメールアドレスを入力してください';
      return;
    }
    
    checking = true;
    emailError = '';
    
    try {
      // サーバーでの重複チェック
      const response = await fetch(`/api/check-email?email=${encodeURIComponent(value)}`);
      const { available } = await response.json();
      
      if (!available) {
        emailError = 'このメールアドレスは既に使用されています';
      }
    } finally {
      checking = false;
    }
  }
  
  $effect(() => {
    clearTimeout(timeoutId);
    
    if (email) {
      timeoutId = setTimeout(() => {
        checkEmail(email);
      }, 500);
    }
  });
</script>

<div class="form-field">
  <input 
    type="email"
    bind:value={email}
    placeholder="メールアドレス"
    class:error={emailError}
  />
  
  {#if checking}
    <span class="checking">確認中...</span>
  {/if}
  
  {#if emailError}
    <span class="error-message">{emailError}</span>
  {/if}
</div>
svelte

高度なuse:enhance

use:enhanceディレクティブをカスタマイズすることで、フォーム送信の前後に独自の処理を追加できます。ローディング状態の管理やエラーハンドリングなど、きめ細かい制御が可能になります。

カスタムエンハンス関数

送信前後の処理をカスタマイズし、ローディング状態やエラー表示を制御する実装例です。

<script lang="ts">
  import { enhance } from '$app/forms';
  import { invalidateAll } from '$app/navigation';
  
  let submitting = $state(false);
  let errors = $state<Record<string, string>>({});
  
  function handleEnhance() {
    return async ({ action, data, form, cancel }) => {
      // 送信前の処理
      submitting = true;
      errors = {};
      
      // カスタムバリデーション
      const email = data.get('email')?.toString();
      if (email && !email.includes('@')) {
        errors.email = '有効なメールアドレスを入力してください';
        cancel();
        submitting = false;
        return;
      }
      
      // デフォルトの送信処理を実行
      return async ({ result, update }) => {
        submitting = false;
        
        if (result.type === 'success') {
          // 成功時の処理
          form.reset();
          await invalidateAll();
        } else if (result.type === 'failure') {
          // エラー時の処理
          errors = result.data?.errors || {};
        }
        
        // デフォルトの更新処理
        await update();
      };
    };
  }
</script>

<form method="POST" use:enhance={handleEnhance}>
  <input 
    name="email" 
    type="email"
    disabled={submitting}
  />
  
  {#if errors.email}
    <span class="error">{errors.email}</span>
  {/if}
  
  <button type="submit" disabled={submitting}>
    {submitting ? '送信中...' : '送信'}
  </button>
</form>
svelte

オプティミスティックUI

オプティミスティックUIは、サーバーレスポンスを待たずに、成功を前提としてUIを即座に更新する手法です。これにより、アプリケーションがより高速に感じられます。エラーが発生した場合は、変更をロールバックします。

<script lang="ts">
  import { enhance } from '$app/forms';
  import type { PageData } from './$types';
  
  let { data }: { data: PageData } = $props();
  
  let todos = $state([...data.todos]);
  let newTodo = $state('');
  
  function handleAddTodo() {
    return async ({ data, cancel }) => {
      const tempId = `temp-${Date.now()}`;
      const tempTodo = {
        id: tempId,
        text: newTodo,
        completed: false,
        pending: true
      };
      
      // オプティミスティックアップデート
      todos = [...todos, tempTodo];
      newTodo = '';
      
      return async ({ result }) => {
        if (result.type === 'success') {
          // 実際のIDで更新
          const index = todos.findIndex(t => t.id === tempId);
          if (index !== -1 && result.data?.todo) {
            todos[index] = result.data.todo;
          }
        } else {
          // エラー時はロールバック
          todos = todos.filter(t => t.id !== tempId);
          newTodo = tempTodo.text;
        }
      };
    };
  }
</script>

<form method="POST" action="?/add" use:enhance={handleAddTodo}>
  <input 
    name="text" 
    bind:value={newTodo}
    placeholder="新しいTODO"
  />
  <button type="submit">追加</button>
</form>

<ul>
  {#each todos as todo}
    <li class:pending={todo.pending}>
      {todo.text}
    </li>
  {/each}
</ul>
svelte

フォームの状態管理

複雑なフォームでは、状態管理が重要になります。Svelte 5のルーンを活用したフォームストアパターンで、再利用可能な状態管理ロジックを実装します。

フォームストアパターン

汎用的なフォームストアを作成し、バリデーション、タッチ状態、送信状態などを一元管理する実装です。

// src/lib/stores/form.svelte.ts
export function createFormStore<T extends Record<string, any>>(
  initialValues: T
) {
  let values = $state(initialValues);
  let errors = $state<Partial<Record<keyof T, string>>>({});
  let touched = $state<Partial<Record<keyof T, boolean>>>({});
  let submitting = $state(false);
  
  return {
    get values() { return values; },
    get errors() { return errors; },
    get touched() { return touched; },
    get submitting() { return submitting; },
    
    setValue<K extends keyof T>(field: K, value: T[K]) {
      values[field] = value;
      touched[field] = true;
    },
    
    setError<K extends keyof T>(field: K, error: string) {
      errors[field] = error;
    },
    
    clearErrors() {
      errors = {};
    },
    
    reset() {
      values = initialValues;
      errors = {};
      touched = {};
      submitting = false;
    },
    
    async submit(action: (values: T) => Promise<void>) {
      submitting = true;
      try {
        await action(values);
      } finally {
        submitting = false;
      }
    }
  };
}
typescript

実践的な実装例

実際のアプリケーションでよく使われる、複雑なフォームパターンの実装例を紹介します。

複雑なフォームウィザード

ステップバイステップでユーザーを導くウィザード形式のフォームです。各ステップでの入力を保持しながら、最後にまとめて送信します。

<!-- src/routes/wizard/+page.svelte -->
<script lang="ts">
  import { enhance } from '$app/forms';
  
  let currentStep = $state(1);
  const totalSteps = 3;
  
  let formData = $state({
    // Step 1
    name: '',
    email: '',
    // Step 2
    company: '',
    position: '',
    // Step 3
    plan: 'basic',
    billing: 'monthly'
  });
  
  function nextStep() {
    if (currentStep < totalSteps) {
      currentStep++;
    }
  }
  
  function prevStep() {
    if (currentStep > 1) {
      currentStep--;
    }
  }
</script>

<div class="wizard">
  <div class="progress">
    {#each Array(totalSteps) as _, i}
      <div 
        class="step" 
        class:active={i + 1 <= currentStep}
      >
        {i + 1}
      </div>
    {/each}
  </div>
  
  <form method="POST" use:enhance>
    {#if currentStep === 1}
      <h2>基本情報</h2>
      <input 
        name="name" 
        bind:value={formData.name}
        placeholder="お名前"
        required
      />
      <input 
        name="email" 
        type="email"
        bind:value={formData.email}
        placeholder="メールアドレス"
        required
      />
    {/if}
    
    {#if currentStep === 2}
      <h2>会社情報</h2>
      <input 
        name="company" 
        bind:value={formData.company}
        placeholder="会社名"
      />
      <input 
        name="position" 
        bind:value={formData.position}
        placeholder="役職"
      />
    {/if}
    
    {#if currentStep === 3}
      <h2>プラン選択</h2>
      <select name="plan" bind:value={formData.plan}>
        <option value="basic">ベーシック</option>
        <option value="pro">プロ</option>
        <option value="enterprise">エンタープライズ</option>
      </select>
      
      <label>
        <input 
          type="radio" 
          name="billing" 
          value="monthly"
          bind:group={formData.billing}
        />
        月払い
      </label>
      
      <label>
        <input 
          type="radio" 
          name="billing" 
          value="yearly"
          bind:group={formData.billing}
        />
        年払い(20%オフ)
      </label>
    {/if}
    
    <div class="buttons">
      {#if currentStep > 1}
        <button type="button" onclick={prevStep}>
          戻る
        </button>
      {/if}
      
      {#if currentStep < totalSteps}
        <button type="button" onclick={nextStep}>
          次へ
        </button>
      {:else}
        <button type="submit">
          登録完了
        </button>
      {/if}
    </div>
  </form>
</div>
svelte

トラブルシューティング

Actionが実行されない
  • ファイル名が +page.server.ts であることを確認
  • export const actions が正しく定義されているか確認
  • フォームの method="POST" が設定されているか確認
フォームデータが取得できない

request.formData() を使用し、適切に型変換

const data = await request.formData();
const value = data.get('field')?.toString() || '';
typescript
use

カスタムエンハンス関数では必ず適切な戻り値を返す。

use:enhance={() => {
  return async ({ result, update }) => {
    await update(); // 忘れずに呼ぶ
  };
}}
javascript

まとめ

SvelteKitのActions とフォーム処理は、

  • プログレッシブ: JavaScript無効でも動作
  • 型安全: TypeScriptで完全サポート
  • 柔軟: カスタマイズ可能なエンハンスメント
  • 実践的: バリデーション、ファイルアップロード対応

次のステップ

Form Actionsの実践的な使用例は以下をご覧ください。

APIルート で、RESTful APIの構築について学びましょう。

Last update at: 2025/10/03 08:42:23