Урок 04

Server Actions

Server Actions — функции на сервере, вызываемые из клиента. Заменяют API-роуты для мутаций.

Определение

// app/actions/posts.ts
'use server';

export async function createPost(formData: FormData) {
    const title = formData.get('title') as string;
    await db.post.create({ data: { title } });
}

'use server' помечает файл или отдельную функцию.

В форме

import { createPost } from '@/app/actions/posts';

export default function Form() {
    return (
        <form action={createPost}>
            <input name="title" />
            <button>Создать</button>
        </form>
    );
}

Работает без JavaScript — обычная HTML-форма.

Inline в серверном компоненте

// app/posts/new/page.tsx
export default function NewPost() {
    async function create(formData: FormData) {
        'use server';
        await db.post.create({ data: { title: formData.get('title') } });
        redirect('/posts');
    }

    return (
        <form action={create}>
            <input name="title" />
            <button>Создать</button>
        </form>
    );
}

useActionState

Состояние, ошибки, pending:

'use client';

import { useActionState } from 'react';
import { createPost } from './actions';

export function Form() {
    const [state, action, pending] = useActionState(createPost, null);

    return (
        <form action={action}>
            <input name="title" />
            {state?.error && <p>{state.error}</p>}
            <button disabled={pending}>
                {pending ? 'Отправка...' : 'Создать'}
            </button>
        </form>
    );
}

Валидация

'use server';

import { z } from 'zod';

const schema = z.object({
    title: z.string().min(3, 'Минимум 3 символа'),
    body: z.string().min(10, 'Минимум 10 символов'),
});

export async function createPost(prevState, formData: FormData) {
    const result = schema.safeParse({
        title: formData.get('title'),
        body: formData.get('body'),
    });

    if (!result.success) {
        return { errors: result.error.flatten().fieldErrors };
    }

    const post = await db.post.create({ data: result.data });

    revalidatePath('/posts');
    redirect(`/posts/${post.id}`);
}

useFormStatus

Для кнопки внутри формы:

'use client';

import { useFormStatus } from 'react-dom';

export function SubmitButton() {
    const { pending } = useFormStatus();

    return (
        <button disabled={pending}>
            {pending ? 'Отправка...' : 'Отправить'}
        </button>
    );
}

Используется в дочерних компонентах формы.

Оптимистичные обновления

'use client';

import { useOptimistic } from 'react';

export function TodoList({ todos, addTodo }) {
    const [optimistic, addOptimistic] = useOptimistic(
        todos,
        (state, newTodo) => [...state, { id: Date.now(), text: newTodo, pending: true }]
    );

    async function handleSubmit(formData: FormData) {
        const text = formData.get('text') as string;
        addOptimistic(text);
        await addTodo(formData);
    }

    return (
        <>
            <form action={handleSubmit}>
                <input name="text" />
                <button>Добавить</button>
            </form>
            <ul>
                {optimistic.map((todo) => (
                    <li key={todo.id} style={{ opacity: todo.pending ? 0.5 : 1 }}>
                        {todo.text}
                    </li>
                ))}
            </ul>
        </>
    );
}

UI обновляется сразу, до ответа сервера.

Безопасность

Server Actions — публичные HTTP-эндпоинты. Всегда проверяй:

'use server';

export async function deletePost(id: number) {
    const session = await auth();
    if (!session) throw new Error('Unauthorized');

    const post = await db.post.findUnique({ where: { id } });
    if (post.authorId !== session.user.id) throw new Error('Forbidden');

    await db.post.delete({ where: { id } });
    revalidatePath('/posts');
}

Вызов из обработчика

'use client';

import { deletePost } from './actions';

export function DeleteButton({ id }: { id: number }) {
    return <button onClick={() => deletePost(id)}>Удалить</button>;
}

Action возвращает промис.

Redirect

'use server';

import { redirect } from 'next/navigation';

export async function createPost(data: FormData) {
    const post = await db.post.create({ data: { title: data.get('title') } });
    redirect(`/posts/${post.id}`);
}

Итоги

  • 'use server' наверху файла
  • action={fn} в форме
  • useActionState для состояния
  • useFormStatus для pending
  • useOptimistic для мгновенного UI
  • Всегда проверяй права