Урок 09

Работа с формами

Формы в 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 Actions
  • useActionState для состояния
  • useFormStatus для pending
  • Zod для валидации
  • Прогрессивное улучшение без JS
  • Прогресс загрузки через XHR