Работа с формами
Формы в Next.js с Server Actions и прогрессивным улучшением.
Простая форма
// app/actions.ts
'use server';
export async function subscribe(formData: FormData) {
const email = formData.get('email') as string;
await db.subscriber.create({ data: { email } });
}
// app/page.tsx
import { subscribe } from './actions';
export default function Page() {
return (
<form action={subscribe}>
<input name="email" type="email" required />
<button>Подписаться</button>
</form>
);
}
Работает без JavaScript.
С состоянием
'use client';
import { useActionState } from 'react';
import { subscribe } from './actions';
export function SubscribeForm() {
const [state, action, pending] = useActionState(subscribe, null);
return (
<form action={action}>
<input name="email" type="email" required />
{state?.error && <p>{state.error}</p>}
{state?.success && <p>Подписка оформлена</p>}
<button disabled={pending}>{pending ? 'Отправка...' : 'Подписаться'}</button>
</form>
);
}
Валидация
'use server';
import { z } from 'zod';
const SubscribeSchema = z.object({
email: z.string().email('Некорректный email'),
});
export async function subscribe(prevState, formData: FormData) {
const result = SubscribeSchema.safeParse({
email: formData.get('email'),
});
if (!result.success) {
return { error: result.error.flatten().fieldErrors.email?.[0] };
}
const exists = await db.subscriber.findUnique({
where: { email: result.data.email },
});
if (exists) {
return { error: 'Этот email уже подписан' };
}
await db.subscriber.create({ data: result.data });
return { success: true };
}
Несколько полей
const RegisterSchema = z.object({
name: z.string().min(2),
email: z.string().email(),
password: z.string().min(8),
confirm: z.string(),
}).refine((data) => data.password === data.confirm, {
message: 'Пароли не совпадают',
path: ['confirm'],
});
export async function register(prevState, formData: FormData) {
const result = RegisterSchema.safeParse(Object.fromEntries(formData));
if (!result.success) {
return { errors: result.error.flatten().fieldErrors };
}
// ...
}
'use client';
export function RegisterForm() {
const [state, action, pending] = useActionState(register, null);
return (
<form action={action}>
<input name="name" />
{state?.errors?.name && <p>{state.errors.name[0]}</p>}
<input name="email" type="email" />
{state?.errors?.email && <p>{state.errors.email[0]}</p>}
<input name="password" type="password" />
{state?.errors?.password && <p>{state.errors.password[0]}</p>}
<input name="confirm" type="password" />
{state?.errors?.confirm && <p>{state.errors.confirm[0]}</p>}
<button disabled={pending}>Зарегистрироваться</button>
</form>
);
}
useFormStatus
Для кнопки внутри формы:
'use client';
import { useFormStatus } from 'react-dom';
export function SubmitButton({ children }: { children: React.ReactNode }) {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? 'Отправка...' : children}
</button>
);
}
Использование:
<form action={action}>
<input name="email" />
<SubmitButton>Подписаться</SubmitButton>
</form>
Сброс после успеха
'use client';
import { useActionState, useEffect, useRef } from 'react';
export function Form() {
const formRef = useRef<HTMLFormElement>(null);
const [state, action] = useActionState(subscribe, null);
useEffect(() => {
if (state?.success) {
formRef.current?.reset();
}
}, [state]);
return (
<form ref={formRef} action={action}>
<input name="email" />
<button>Подписаться</button>
</form>
);
}
Файлы
'use server';
export async function upload(prevState, formData: FormData) {
const file = formData.get('file') as File;
if (!file || file.size === 0) {
return { error: 'Файл не выбран' };
}
if (file.size > 5 * 1024 * 1024) {
return { error: 'Максимум 5 МБ' };
}
const allowed = ['image/jpeg', 'image/png', 'image/webp'];
if (!allowed.includes(file.type)) {
return { error: 'Только JPEG, PNG, WebP' };
}
const buffer = Buffer.from(await file.arrayBuffer());
const filename = `${crypto.randomUUID()}-${file.name}`;
await fs.writeFile(`./public/uploads/${filename}`, buffer);
revalidatePath('/gallery');
return { success: true, url: `/uploads/${filename}` };
}
Прогресс загрузки
Server Actions не дают прогресс. Для больших файлов — отдельный API-роут с XHR:
function upload(file: File, onProgress: (percent: number) => void) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) onProgress((e.loaded / e.total) * 100);
};
xhr.onload = () => resolve(JSON.parse(xhr.response));
xhr.onerror = () => reject(new Error('Upload failed'));
const data = new FormData();
data.append('file', file);
xhr.open('POST', '/api/upload');
xhr.send(data);
});
}
Форма с несколькими кнопками
'use server';
export async function save(formData: FormData) {
const action = formData.get('action');
if (action === 'draft') {
await db.post.create({ data: { ...data, published: false } });
} else if (action === 'publish') {
await db.post.create({ data: { ...data, published: true } });
}
}
<form action={save}>
<input name="title" />
<button name="action" value="draft">Сохранить черновик</button>
<button name="action" value="publish">Опубликовать</button>
</form>
Динамические поля
'use client';
import { useState } from 'react';
export function Form() {
const [fields, setFields] = useState([{ id: 1, value: '' }]);
return (
<form action={submit}>
{fields.map((field, i) => (
<div key={field.id}>
<input name={`items[${i}]`} defaultValue={field.value} />
</div>
))}
<button type="button" onClick={() => setFields([...fields, { id: Date.now(), value: '' }])}>
Добавить
</button>
<button>Отправить</button>
</form>
);
}
Итоги
action={fn}для Server ActionsuseActionStateдля состоянияuseFormStatusдля pending- Zod для валидации
- Прогрессивное улучшение без JS
- Прогресс загрузки через XHR