Server Actions: продвинуто
Оптимистичные обновления
UI обновляется сразу, до ответа сервера.
'use client';
import { useOptimistic } from 'react';
type Todo = { id: number; text: string; pending?: boolean };
export function TodoList({
todos,
addTodo,
}: {
todos: Todo[];
addTodo: (formData: FormData) => Promise<void>;
}) {
const [optimistic, addOptimistic] = useOptimistic(
todos,
(state: Todo[], newText: string) => [
...state,
{ id: Date.now(), text: newText, 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" required />
<button>Добавить</button>
</form>
<ul>
{optimistic.map((todo) => (
<li key={todo.id} style={{ opacity: todo.pending ? 0.5 : 1 }}>
{todo.text}
</li>
))}
</ul>
</>
);
}
Пользователь видит новую задачу мгновенно, даже если сервер отвечает медленно.
Формы с несколькими actions
'use client';
import { useActionState } from 'react';
export function Form({ save, publish }: { save: any; publish: any }) {
const [saveState, saveAction, savePending] = useActionState(save, null);
const [publishState, publishAction, publishPending] = useActionState(publish, null);
return (
<>
<form action={saveAction}>
<input name="title" />
<button disabled={savePending}>Сохранить</button>
</form>
<form action={publishAction}>
<input name="title" />
<button disabled={publishPending}>Опубликовать</button>
</form>
</>
);
}
Передача данных через bind
'use server';
export async function updatePost(id: number, formData: FormData) {
await db.post.update({ where: { id }, data: { title: formData.get('title') } });
}
const updateWithId = updatePost.bind(null, post.id);
<form action={updateWithId}>...</form>
Обработка ошибок
'use server';
export async function createPost(prevState: any, formData: FormData) {
try {
const post = await db.post.create({
data: { title: formData.get('title') },
});
revalidatePath('/posts');
return { success: true, id: post.id };
} catch (error) {
return { error: 'Не удалось создать пост' };
}
}
Ошибки возвращаются как состояние, а не выбрасываются.
Валидация через Zod
'use server';
import { z } from 'zod';
const PostSchema = z.object({
title: z.string().min(3).max(100),
body: z.string().min(10),
published: z.coerce.boolean().default(false),
});
export async function createPost(prevState, formData: FormData) {
const result = PostSchema.safeParse({
title: formData.get('title'),
body: formData.get('body'),
published: formData.get('published'),
});
if (!result.success) {
return { errors: result.error.flatten().fieldErrors };
}
await db.post.create({ data: result.data });
revalidatePath('/posts');
redirect('/posts');
}
Форма
'use client';
export function Form() {
const [state, action, pending] = useActionState(createPost, null);
return (
<form action={action}>
<div>
<input name="title" />
{state?.errors?.title && <p>{state.errors.title[0]}</p>}
</div>
<div>
<textarea name="body" />
{state?.errors?.body && <p>{state.errors.body[0]}</p>}
</div>
<button disabled={pending}>{pending ? '...' : 'Создать'}</button>
</form>
);
}
Загрузка файлов
'use server';
export async function uploadFile(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 buffer = Buffer.from(await file.arrayBuffer());
await fs.writeFile(`./uploads/${file.name}`, buffer);
revalidatePath('/files');
return { success: true };
}
Прогресс
Server Actions не дают прогресс загрузки. Для больших файлов — XMLHttpRequest на отдельном API-роуте.
Безопасность
'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) throw new Error('Not found');
if (post.authorId !== session.user.id) throw new Error('Forbidden');
await db.post.delete({ where: { id } });
revalidatePath('/posts');
}
Всегда проверяй:
- Аутентификацию
- Права
- Существование записи
- Валидацию
Actions в клиентских компонентах
Можно импортировать и вызывать напрямую:
'use client';
import { deletePost } from '@/app/actions';
export function DeleteButton({ id }: { id: number }) {
return (
<button onClick={async () => {
await deletePost(id);
}}>
Удалить
</button>
);
}
Итоги
useOptimisticдля мгновенного UIbindдля передачи аргументов- Ошибки возвращай, не бросай
- Zod для валидации
- Проверяй права всегда
- Actions можно вызывать напрямую