Красивые формы без перегруженного фронта
Server Actions + Zod + минимальная клиентская логика. Разбираю шаблон, который использую почти во всех лендингах.
Большинство лендингов требуют ровно одну форму: «оставьте заявку». Странно тащить ради этого React-Hook-Form, react-query и валидатор на 30 КБ.
Покажу подход, в котором я использую Server Actions и Zod, а на клиенте — только
useFormStatus для индикации загрузки.
Шаг 1. Схема валидации
// lib/contact.ts
import { z } from "zod"
export const contactSchema = z.object({
name: z.string().min(2, "Имя слишком короткое"),
email: z.string().email("Похоже на опечатку"),
message: z.string().min(10, "Расскажите чуть подробнее"),
})
export type ContactInput = z.infer<typeof contactSchema>Одна схема — два потребителя: серверный экшн и (опционально) клиент. Это даёт единый источник правды и автоматический вывод типов.
Шаг 2. Server Action
"use server"
import { contactSchema } from "@/lib/contact"
type State = { ok: boolean; errors?: Record<string, string> }
export async function submitContact(_: State, formData: FormData): Promise<State> {
const parsed = contactSchema.safeParse(Object.fromEntries(formData))
if (!parsed.success) {
const errors: Record<string, string> = {}
for (const issue of parsed.error.issues) {
errors[String(issue.path[0])] = issue.message
}
return { ok: false, errors }
}
await sendToTelegram(parsed.data) // или email, или CRM
return { ok: true }
}Шаг 3. Форма
"use client"
import { useActionState } from "react"
import { useFormStatus } from "react-dom"
import { submitContact } from "./actions"
export function ContactForm() {
const [state, action] = useActionState(submitContact, { ok: false })
if (state.ok) return <p>Спасибо! Я отвечу в течение суток.</p>
return (
<form action={action} className="space-y-6">
<Field name="name" label="Имя" error={state.errors?.name} />
<Field name="email" label="Email" error={state.errors?.email} />
<Field name="message" label="Сообщение" textarea error={state.errors?.message} />
<SubmitButton />
</form>
)
}
function SubmitButton() {
const { pending } = useFormStatus()
return <button disabled={pending}>{pending ? "Отправляю…" : "Отправить"}</button>
}Что мы получили
| Метрика | Раньше (RHF + react-query) | Сейчас |
|---|---|---|
| JS на странице | ~70 КБ | ~6 КБ |
| Перерендеры на ввод | каждый символ | ноль |
| Серверная валидация | дублируется | переиспользуется |
| Поддержка без JS | требует костыли | работает «из коробки» |
Чек-лист перед запуском формы
- Серверная валидация по той же схеме, что клиент видит в подсказках
- Защита от ботов (honeypot или Turnstile)
- Состояния: пусто, ошибка, отправка, успех
- Логирование: куда ушла заявка и пришла ли она в CRM
- Уведомление в Telegram-канал — чтобы видеть заявки в реальном времени
Цитата на закуску
Лучшая форма — та, которую можно отправить с клавиатуры, без мыши, и она работает, даже когда у клиента отвалился JavaScript.
— каждый, кто хоть раз чинил продакшн в пятницу
Этот шаблон я переношу из проекта в проект почти без изменений. Если хочется — забирайте.