Урок 07

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 для мгновенного UI
  • bind для передачи аргументов
  • Ошибки возвращай, не бросай
  • Zod для валидации
  • Проверяй права всегда
  • Actions можно вызывать напрямую