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для pendinguseOptimisticдля мгновенного UI- Всегда проверяй права