Урок 06

Server Actions и мутации

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

Определение

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

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

'use server' наверху файла помечает все экспорты как Server Actions.

В форме

import { createPost } from './actions';

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

action принимает Server Action. Работает без JavaScript.

С аргументами

<form action={createPost}>
    <input name="title" />
</form>

FormData передаётся автоматически.

Или через bind:

const createWithUserId = createPost.bind(null, userId);
<form action={createWithUserId}>...</form>

useActionState

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

'use client';

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

export default function Form() {
    const [state, formAction, isPending] = useActionState(createPost, null);

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

Action возвращает { error: string } или { success: true }.

Валидация

'use server';

export async function createPost(prevState: any, formData: FormData) {
    const title = formData.get('title') as string;

    if (!title || title.length < 3) {
        return { error: 'Заголовок слишком короткий' };
    }

    await db.post.create({ data: { title } });
    revalidatePath('/posts');

    return { success: true };
}

revalidatePath

Обновить данные на странице:

import { revalidatePath } from 'next/cache';

await db.post.create({ data });
revalidatePath('/posts');

revalidateTag

Если fetch был с тегом:

revalidateTag('posts');

redirect

import { redirect } from 'next/navigation';

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

Server Actions в обработчиках

'use client';

import { deletePost } from './actions';

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

Action может быть вызвана и напрямую.

С типами

'use server';

type ActionResult = { error?: string; success?: boolean };

export async function createPost(
    prevState: ActionResult | null,
    formData: FormData
): Promise<ActionResult> {
    // ...
}

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

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

  • Авторизацию
  • Права доступа
  • Валидацию
'use server';

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

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

    await db.post.delete({ where: { id } });
}

Итоги

  • Server Actions — мутации без API
  • 'use server' наверху файла
  • action={fn} в форме
  • useActionState для состояния
  • revalidatePath и revalidateTag
  • Всегда проверяй права