Перейти к содержимому

24. SvelteKit: Form Actions

Привет! 👋 Формы — это основа веба. И SvelteKit сделал работу с ними элегантной и надёжной. Form Actions — это серверные обработчики форм, которые работают даже без JavaScript! А с use:enhance они становятся молниеносными.

Думай о Form Actions как о старом добром PHP обработчике форм, только на стероидах 💪. Простота без JS, скорость с JS, типобезопасность всегда.


Form Actions — это экспортируемые функции в +page.server.ts, которые обрабатывают POST запросы. Они живут рядом с load функцией и имеют доступ ко всему серверному окружению.

src/routes/login/+page.server.ts
import type { Actions, PageServerLoad } from './$types';
import { fail, redirect } from '@sveltejs/kit';
// load и actions могут быть в одном файле:
export const load: PageServerLoad = async ({ locals }) => {
if (locals.user) {
redirect(307, '/dashboard');
}
return {};
};
// Дефолтное action (когда одно):
export const actions: Actions = {
default: async ({ request, cookies, locals }) => {
const data = await request.formData();
const email = data.get('email') as string;
const password = data.get('password') as string;
// Валидация:
if (!email || !email.includes('@')) {
return fail(400, {
email,
error: 'Введите корректный email',
});
}
if (!password || password.length < 8) {
return fail(400, {
email,
error: 'Пароль должен быть минимум 8 символов',
});
}
// Проверяем пользователя:
const user = await db.user.findUnique({ where: { email } });
if (!user || !(await verifyPassword(password, user.passwordHash))) {
return fail(401, {
email,
error: 'Неверный email или пароль',
});
}
// Создаём сессию:
const session = await createSession(user.id);
cookies.set('session', session.token, {
path: '/',
httpOnly: true,
secure: true,
maxAge: 60 * 60 * 24 * 7, // 7 дней
sameSite: 'strict',
});
redirect(303, '/dashboard');
},
};
src/routes/login/+page.svelte
<script lang="ts">
import type { PageData, ActionData } from './$types';
// data — из load функции
// form — результат Action (null если ещё не отправляли)
let { data, form }: { data: PageData; form: ActionData } = $props();
</script>
<!-- Форма без use:enhance — работает БЕЗ JavaScript! -->
<form method="POST">
<label>
Email
<input
type="email"
name="email"
value={form?.email ?? ''}
class:error={form?.error}
/>
</label>
<label>
Пароль
<input type="password" name="password" />
</label>
{#if form?.error}
<p class="error">{form.error}</p>
{/if}
<button type="submit">Войти</button>
</form>

Когда на одной странице несколько форм — используй именованные actions:

src/routes/settings/+page.server.ts
import type { Actions } from './$types';
import { fail } from '@sveltejs/kit';
export const actions: Actions = {
// Обновление профиля:
updateProfile: async ({ request, locals }) => {
const data = await request.formData();
const name = data.get('name') as string;
const bio = data.get('bio') as string;
if (!name?.trim()) {
return fail(400, { updateProfile: { error: 'Имя обязательно' } });
}
await db.user.update({
where: { id: locals.user!.id },
data: { name, bio },
});
return { updateProfile: { success: true } };
},
// Смена пароля:
changePassword: async ({ request, locals }) => {
const data = await request.formData();
const currentPassword = data.get('current') as string;
const newPassword = data.get('new') as string;
const user = await db.user.findUnique({
where: { id: locals.user!.id },
});
if (!(await verifyPassword(currentPassword, user!.passwordHash))) {
return fail(400, { changePassword: { error: 'Неверный текущий пароль' } });
}
if (newPassword.length < 8) {
return fail(400, { changePassword: { error: 'Пароль минимум 8 символов' } });
}
await db.user.update({
where: { id: locals.user!.id },
data: { passwordHash: await hashPassword(newPassword) },
});
return { changePassword: { success: true, message: 'Пароль изменён!' } };
},
// Удаление аккаунта:
deleteAccount: async ({ request, locals, cookies }) => {
const data = await request.formData();
const confirm = data.get('confirm');
if (confirm !== 'DELETE') {
return fail(400, { deleteAccount: { error: 'Введите DELETE для подтверждения' } });
}
await db.user.delete({ where: { id: locals.user!.id } });
cookies.delete('session', { path: '/' });
redirect(303, '/goodbye');
},
};
src/routes/settings/+page.svelte
<script lang="ts">
import type { ActionData } from './$types';
let { form }: { form: ActionData } = $props();
</script>
<!-- Именованное action — ?/updateProfile в action -->
<form method="POST" action="?/updateProfile">
<input name="name" placeholder="Имя" />
<textarea name="bio" placeholder="О себе"></textarea>
{#if form?.updateProfile?.error}
<p class="error">{form.updateProfile.error}</p>
{/if}
{#if form?.updateProfile?.success}
<p class="success">✅ Профиль обновлён!</p>
{/if}
<button type="submit">Сохранить профиль</button>
</form>
<form method="POST" action="?/changePassword">
<input type="password" name="current" placeholder="Текущий пароль" />
<input type="password" name="new" placeholder="Новый пароль" />
{#if form?.changePassword?.error}
<p class="error">{form.changePassword.error}</p>
{/if}
<button type="submit">Сменить пароль</button>
</form>
<form method="POST" action="?/deleteAccount">
<input name="confirm" placeholder='Введите "DELETE"' />
{#if form?.deleteAccount?.error}
<p class="error">{form.deleteAccount.error}</p>
{/if}
<button type="submit" class="danger">Удалить аккаунт</button>
</form>

use:enhance превращает обычную HTML форму в молниеносную SPA-форму:

<script lang="ts">
import { enhance } from '$app/forms';
import type { ActionData } from './$types';
let { form }: { form: ActionData } = $props();
let isSubmitting = $state(false);
</script>
<!-- С use:enhance — AJAX запрос, без перезагрузки страницы -->
<form
method="POST"
use:enhance={({ formElement, formData, action, cancel, submitter }) => {
// Запускается ДО отправки:
isSubmitting = true;
// Можно отменить отправку:
// if (!isValid) { cancel(); return; }
// Можно модифицировать данные:
// formData.append('timestamp', Date.now().toString());
// Возвращаем callback который запускается ПОСЛЕ:
return async ({ result, update }) => {
isSubmitting = false;
if (result.type === 'success') {
// Показываем уведомление:
showToast('Сохранено!');
}
// update() обновляет form store и вызывает invalidateAll()
await update();
};
}}
>
<input name="email" type="email" />
<input name="password" type="password" />
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Входим...' : 'Войти'}
</button>
</form>

Минимальный use:enhance (без кастомизации):

<form method="POST" use:enhance>
<!-- SvelteKit автоматически: -->
<!-- 1. Перехватит submit -->
<!-- 2. Отправит AJAX запрос -->
<!-- 3. Обновит form store -->
<!-- 4. Вызовет invalidateAll() если успех -->
<!-- 5. Покажет ошибки если fail() -->
</form>

<script lang="ts">
import { enhance, applyAction, deserialize } from '$app/forms';
import type { ActionResult } from '@sveltejs/kit';
// ActionResult — union type для всех возможных результатов:
type ActionResult =
| { type: 'success'; status: number; data?: Record<string, unknown> }
| { type: 'failure'; status: number; data?: Record<string, unknown> }
| { type: 'redirect'; status: number; location: string }
| { type: 'error'; status?: number; error: any };
// Ручная обработка result:
function handleResult(result: ActionResult) {
if (result.type === 'success') {
console.log('Успех!', result.data);
} else if (result.type === 'failure') {
console.log('Ошибка:', result.data?.error);
} else if (result.type === 'redirect') {
// applyAction применяет результат как SvelteKit
applyAction(result); // Перейдёт по redirect URL
} else if (result.type === 'error') {
console.error('Серверная ошибка:', result.error);
}
}
</script>
<form
method="POST"
use:enhance={() => async ({ result }) => {
// Полный контроль над результатом:
if (result.type === 'redirect') {
await applyAction(result); // Применяем redirect
} else {
await applyAction(result); // Обновляем form store
}
}}
>

// src/lib/validation.ts — общая библиотека валидации
export interface ValidationError {
field: string;
message: string;
}
export function validateEmail(email: string): ValidationError | null {
if (!email) return { field: 'email', message: 'Email обязателен' };
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return { field: 'email', message: 'Неверный формат email' };
}
return null;
}
export function validatePassword(password: string): ValidationError | null {
if (!password) return { field: 'password', message: 'Пароль обязателен' };
if (password.length < 8) return { field: 'password', message: 'Минимум 8 символов' };
if (!/[A-Z]/.test(password)) return { field: 'password', message: 'Нужна заглавная буква' };
if (!/[0-9]/.test(password)) return { field: 'password', message: 'Нужна цифра' };
return null;
}
// В action:
export const actions: Actions = {
default: async ({ request }) => {
const data = await request.formData();
const email = data.get('email') as string;
const password = data.get('password') as string;
const errors: Record<string, string> = {};
const emailError = validateEmail(email);
if (emailError) errors[emailError.field] = emailError.message;
const passwordError = validatePassword(password);
if (passwordError) errors[passwordError.field] = passwordError.message;
if (Object.keys(errors).length > 0) {
return fail(400, { errors, values: { email } });
}
// Продолжаем...
},
};
<!-- Форма с полевой валидацией: -->
<script lang="ts">
import type { ActionData } from './$types';
let { form }: { form: ActionData } = $props();
</script>
<form method="POST" use:enhance>
<div class:has-error={form?.errors?.email}>
<label>Email</label>
<input
name="email"
type="email"
value={form?.values?.email ?? ''}
/>
{#if form?.errors?.email}
<span class="field-error">{form.errors.email}</span>
{/if}
</div>
<div class:has-error={form?.errors?.password}>
<label>Пароль</label>
<input name="password" type="password" />
{#if form?.errors?.password}
<span class="field-error">{form.errors.password}</span>
{/if}
</div>
<button type="submit">Отправить</button>
</form>

src/routes/upload/+page.server.ts
import type { Actions } from './$types';
import { fail } from '@sveltejs/kit';
import { writeFile } from 'fs/promises';
import { join } from 'path';
export const actions: Actions = {
default: async ({ request, locals }) => {
const data = await request.formData();
const file = data.get('avatar') as File;
if (!file || file.size === 0) {
return fail(400, { error: 'Файл не выбран' });
}
if (file.size > 5 * 1024 * 1024) { // 5MB
return fail(400, { error: 'Файл слишком большой (макс. 5MB)' });
}
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
if (!allowedTypes.includes(file.type)) {
return fail(400, { error: 'Только JPEG, PNG и WebP изображения' });
}
const buffer = Buffer.from(await file.arrayBuffer());
const filename = `${locals.user!.id}-${Date.now()}.${file.type.split('/')[1]}`;
const uploadPath = join('static', 'uploads', filename);
await writeFile(uploadPath, buffer);
await db.user.update({
where: { id: locals.user!.id },
data: { avatarUrl: `/uploads/${filename}` },
});
return { success: true, avatarUrl: `/uploads/${filename}` };
},
};
<form method="POST" enctype="multipart/form-data" use:enhance>
<input
type="file"
name="avatar"
accept="image/*"
/>
<button type="submit">Загрузить аватар</button>
</form>

<script lang="ts">
import { enhance } from '$app/forms';
interface Todo {
id: string;
text: string;
done: boolean;
}
let { data } = $props();
let todos = $state<Todo[]>(data.todos);
function addTodo(event: SubmitEvent) {
const form = event.target as HTMLFormElement;
const text = new FormData(form).get('text') as string;
// Оптимистично добавляем TODO сразу:
const tempId = 'temp-' + Date.now();
todos = [...todos, { id: tempId, text, done: false }];
return async ({ result, update }: any) => {
if (result.type === 'failure') {
// Откатываем если ошибка:
todos = todos.filter(t => t.id !== tempId);
} else if (result.type === 'success') {
// Заменяем временный ID на реальный:
todos = todos.map(t =>
t.id === tempId ? { ...t, id: result.data.id } : t
);
}
await update({ reset: true }); // Сбрасываем форму
};
}
function toggleTodo(id: string) {
// Оптимистичное переключение:
todos = todos.map(t => t.id === id ? { ...t, done: !t.done } : t);
return async ({ result }: any) => {
if (result.type === 'failure') {
// Откатываем:
todos = todos.map(t => t.id === id ? { ...t, done: !t.done } : t);
}
};
}
</script>
<form method="POST" action="?/addTodo" use:enhance={addTodo}>
<input name="text" placeholder="Новое задание..." />
<button type="submit">Добавить</button>
</form>
{#each todos as todo (todo.id)}
<form method="POST" action="?/toggleTodo" use:enhance={toggleTodo(todo.id)}>
<input type="hidden" name="id" value={todo.id} />
<button type="submit" class:done={todo.done}>
{todo.done ? '✅' : '⬜'} {todo.text}
</button>
</form>
{/each}

Характеристика SvelteKit Form Actions Next.js Server Actions
──────────────────────────────────────────────────────────────────────
Работают без JS ✅ Да (нативные формы) ⚠️ Частично
Progressive enhancement ✅ use:enhance ✅ useFormState
Файл +page.server.ts Любой Server Component
TypeScript ✅ Автовывод типов ✅ Явные типы
Файлы (upload) ✅ FormData из коробки ✅ FormData из коробки
Валидация Ручная / Zod Ручная / Zod
Реактивный state $page.form useFormState/useActionState
Оптимистичный UI use:enhance callback useOptimistic