11. Server Actions
⚡ Server Actions: мутации без API
Заголовок раздела «⚡ Server Actions: мутации без API»Server Actions — одна из самых крутых фич App Router! Представь: ты пишешь обычную async-функцию с 'use server', а Next.js автоматически создаёт для неё API-эндпоинт, подключает её к форме и обрабатывает всё — без тебя! Как будто React и сервер наконец-то пожали друг другу руки 🤝
🎯 Базовый синтаксис: ‘use server’
Заголовок раздела «🎯 Базовый синтаксис: ‘use server’»Директива 'use server' говорит Next.js: “эта функция выполняется только на сервере”. Можно поставить её вверху файла или прямо внутри функции:
// app/actions.ts — отдельный файл для actions'use server'; // директива в начале файла = все функции — Server Actions
import { db } from '@/lib/db';import { revalidatePath } from 'next/cache';
export async function createPost(title: string, content: string) { const post = await db.post.create({ data: { title, content, authorId: getCurrentUserId() }, });
// Обновляем кэш страницы revalidatePath('/blog'); revalidatePath(`/blog/${post.slug}`);
return post;}
export async function deletePost(id: number) { await db.post.delete({ where: { id } }); revalidatePath('/blog');}Или инлайн в компоненте:
// app/blog/new/page.tsx — Server Componentexport default function NewPostPage() { // Инлайн Server Action async function createPost(formData: FormData) { 'use server'; // директива внутри функции
const title = formData.get('title') as string; const content = formData.get('content') as string;
await db.post.create({ data: { title, content } }); revalidatePath('/blog'); redirect('/blog'); }
return ( <form action={createPost}> <input name="title" placeholder="Заголовок" /> <textarea name="content" placeholder="Содержание" /> <button type="submit">Опубликовать</button> </form> );}📋 Form Actions: нативная работа с формами
Заголовок раздела «📋 Form Actions: нативная работа с формами»Server Actions прекрасно работают с HTML-формами через атрибут action. Это даже работает без JavaScript — Progressive Enhancement в действии! 🌟
'use server';
import { z } from 'zod';import { revalidatePath } from 'next/cache';import { redirect } from 'next/navigation';
const TodoSchema = z.object({ title: z.string().min(1, 'Заголовок обязателен').max(100), description: z.string().max(500).optional(), priority: z.enum(['low', 'medium', 'high']),});
// Возвращаем состояние для useFormStateexport type FormState = { success?: boolean; error?: string; fieldErrors?: Record<string, string[]>;};
export async function createTodo( prevState: FormState, formData: FormData): Promise<FormState> { const raw = { title: formData.get('title'), description: formData.get('description'), priority: formData.get('priority'), };
const result = TodoSchema.safeParse(raw);
if (!result.success) { return { error: 'Ошибка валидации', fieldErrors: result.error.flatten().fieldErrors, }; }
try { await db.todo.create({ data: result.data }); revalidatePath('/todos'); return { success: true }; } catch (error) { return { error: 'Не удалось создать задачу' }; }}🔄 useFormState: состояние формы
Заголовок раздела «🔄 useFormState: состояние формы»useFormState (в React 19: useActionState) позволяет получать ответ от Server Action прямо в компоненте:
'use client';
import { useFormState, useFormStatus } from 'react-dom';import { createTodo, type FormState } from '@/app/actions/todo';
const initialState: FormState = {};
// Компонент кнопки знает о статусе отправки формыfunction SubmitButton() { const { pending } = useFormStatus();
return ( <button type="submit" disabled={pending} className={pending ? 'opacity-50 cursor-not-allowed' : ''} > {pending ? '⏳ Создание...' : '✅ Создать задачу'} </button> );}
export default function NewTodoForm() { const [state, formAction] = useFormState(createTodo, initialState);
return ( <form action={formAction} className="space-y-4"> {state.error && ( <div className="bg-red-100 text-red-700 p-3 rounded"> ❌ {state.error} </div> )}
{state.success && ( <div className="bg-green-100 text-green-700 p-3 rounded"> ✅ Задача создана! </div> )}
<div> <input name="title" placeholder="Что нужно сделать?" className="w-full border rounded p-2" /> {state.fieldErrors?.title && ( <p className="text-red-500 text-sm mt-1"> {state.fieldErrors.title[0]} </p> )} </div>
<select name="priority" defaultValue="medium"> <option value="low">🟢 Низкий</option> <option value="medium">🟡 Средний</option> <option value="high">🔴 Высокий</option> </select>
<SubmitButton /> </form> );}🚀 useFormStatus: индикатор загрузки
Заголовок раздела «🚀 useFormStatus: индикатор загрузки»useFormStatus — хук, доступный только внутри компонента-потомка формы. Он знает, в каком состоянии находится ближайшая родительская форма:
'use client';
import { useFormStatus } from 'react-dom';
interface SubmitButtonProps { children: React.ReactNode; loadingText?: string;}
export function SubmitButton({ children, loadingText = 'Загрузка...' }: SubmitButtonProps) { const { pending, data, method, action } = useFormStatus();
// data — FormData текущей отправки // method — 'get' или 'post' // action — ссылка на функцию action
return ( <button type="submit" disabled={pending} aria-busy={pending} className={` px-4 py-2 rounded font-medium transition-all ${pending ? 'bg-gray-400 cursor-not-allowed' : 'bg-blue-600 hover:bg-blue-700 text-white' } `} > {pending ? ( <span className="flex items-center gap-2"> <span className="animate-spin">⏳</span> {loadingText} </span> ) : children} </button> );}🎭 Оптимистичные обновления: useOptimistic
Заголовок раздела «🎭 Оптимистичные обновления: useOptimistic»useOptimistic позволяет немедленно обновить UI, не дожидаясь ответа сервера. Как предсказывать будущее, но для интерфейсов 🔮
'use client';
import { useOptimistic, useTransition } from 'react';import { toggleTodo, deleteTodo } from '@/app/actions/todo';
interface Todo { id: number; title: string; completed: boolean;}
interface Props { initialTodos: Todo[];}
export function TodoList({ initialTodos }: Props) { const [isPending, startTransition] = useTransition();
// useOptimistic: локальное состояние + функция-редьюсер const [optimisticTodos, addOptimisticUpdate] = useOptimistic( initialTodos, (state: Todo[], action: { type: 'toggle' | 'delete'; id: number }) => { if (action.type === 'toggle') { return state.map(todo => todo.id === action.id ? { ...todo, completed: !todo.completed } : todo ); } if (action.type === 'delete') { return state.filter(todo => todo.id !== action.id); } return state; } );
const handleToggle = (id: number) => { startTransition(async () => { // Мгновенно обновляем UI addOptimisticUpdate({ type: 'toggle', id }); // Отправляем на сервер (может занять время) await toggleTodo(id); }); };
const handleDelete = (id: number) => { startTransition(async () => { addOptimisticUpdate({ type: 'delete', id }); await deleteTodo(id); }); };
return ( <ul className="space-y-2"> {optimisticTodos.map(todo => ( <li key={todo.id} className={`flex items-center gap-3 p-3 rounded ${ todo.completed ? 'opacity-60' : '' }`} > <input type="checkbox" checked={todo.completed} onChange={() => handleToggle(todo.id)} /> <span className={todo.completed ? 'line-through' : ''}> {todo.title} </span> <button onClick={() => handleDelete(todo.id)}>🗑️</button> </li> ))} </ul> );}🔄 Revalidation: обновление кэша
Заголовок раздела «🔄 Revalidation: обновление кэша»После мутации нужно обновить данные на странице. Для этого есть два основных метода:
'use server';
import { revalidatePath, revalidateTag } from 'next/cache';import { redirect } from 'next/navigation';
export async function updateProduct(id: number, data: FormData) { const name = data.get('name') as string; const price = parseFloat(data.get('price') as string);
await db.product.update({ where: { id }, data: { name, price }, });
// Инвалидация по пути — перерендерит страницу revalidatePath('/products'); // список revalidatePath(`/products/${id}`); // деталь revalidatePath('/admin/products'); // админка
// Инвалидация по тегу — если данные тегированы в fetch revalidateTag('products'); revalidateTag(`product-${id}`);
// Редирект после мутации (выбрасывает исключение внутри!) redirect('/products');}
// В fetch можно пометить данные тегами:async function getProduct(id: number) { const data = await fetch(`/api/products/${id}`, { next: { tags: [`product-${id}`, 'products'] } }); return data.json();}🔐 Безопасность Server Actions
Заголовок раздела «🔐 Безопасность Server Actions»Server Actions выполняются на сервере, но вызываются из клиента — значит, нужна защита! ⚠️
'use server';
import { auth } from '@/lib/auth';import { headers } from 'next/headers';
// ❌ Плохо: доверяем данным без проверкиexport async function badDeletePost(id: number) { await db.post.delete({ where: { id } }); // Любой может удалить любой пост!}
// ✅ Хорошо: проверяем аутентификацию и авторизациюexport async function deletePost(id: number) { // 1. Проверяем аутентификацию const session = await auth(); if (!session?.user) { throw new Error('Не авторизован'); }
// 2. Получаем пост и проверяем владельца const post = await db.post.findUnique({ where: { id } });
if (!post) { throw new Error('Пост не найден'); }
// 3. Проверяем авторизацию if (post.authorId !== session.user.id && session.user.role !== 'admin') { throw new Error('Нет прав на удаление'); }
await db.post.delete({ where: { id } }); revalidatePath('/blog');}
// CSRF защита — Next.js добавляет автоматически для форм!// Но для программных вызовов (fetch) нужно использовать// заголовок 'Next-Action' который Next.js проверяет сам.
// Валидация ВСЕГДА на сервере (клиентская — только UX)export async function createUser(formData: FormData) { const headersList = await headers(); const origin = headersList.get('origin');
// Проверяем origin if (origin !== process.env.NEXT_PUBLIC_APP_URL) { throw new Error('Invalid origin'); }
// Валидируем все данные серверной стороной const schema = z.object({ email: z.string().email(), name: z.string().min(2).max(50), });
const result = schema.safeParse({ email: formData.get('email'), name: formData.get('name'), });
if (!result.success) { return { error: result.error.flatten() }; }
// Теперь безопасно работаем с данными await db.user.create({ data: result.data });}🎯 Вызов Server Actions из обработчиков событий
Заголовок раздела «🎯 Вызов Server Actions из обработчиков событий»Server Actions можно вызывать не только из форм, но и из обычных обработчиков:
'use client';
import { likePost } from '@/app/actions/posts';import { useState, useTransition } from 'react';
export function LikeButton({ postId, initialLikes }: { postId: number; initialLikes: number;}) { const [likes, setLikes] = useState(initialLikes); const [liked, setLiked] = useState(false); const [isPending, startTransition] = useTransition();
const handleLike = () => { if (liked) return;
// Оптимистичное обновление setLikes(prev => prev + 1); setLiked(true);
startTransition(async () => { try { const result = await likePost(postId); // Синхронизируем с реальным значением от сервера setLikes(result.totalLikes); } catch (error) { // Откат при ошибке setLikes(prev => prev - 1); setLiked(false); } }); };
return ( <button onClick={handleLike} disabled={liked || isPending} className={`flex items-center gap-2 px-4 py-2 rounded-full transition-all ${ liked ? 'bg-red-100 text-red-600' : 'bg-gray-100 hover:bg-red-50' }`} > {liked ? '❤️' : '🤍'} {likes} </button> );}🔗 Связь с redirect и notFound
Заголовок раздела «🔗 Связь с redirect и notFound»'use server';
import { redirect, notFound, permanentRedirect } from 'next/navigation';
export async function createAndRedirect(formData: FormData) { const title = formData.get('title') as string;
if (!title) { return { error: 'Заголовок обязателен' }; }
const post = await db.post.create({ data: { title, slug: slugify(title) }, });
// redirect выбрасывает исключение NEXT_REDIRECT — не оборачивай в try-catch! redirect(`/blog/${post.slug}`);}
export async function getPostOrNotFound(slug: string) { const post = await db.post.findUnique({ where: { slug } });
if (!post) { // notFound() выбрасывает исключение NEXT_NOT_FOUND notFound(); // показывает ближайший not-found.tsx }
return post;}
export async function handleOldUrl(oldSlug: string) { const newSlug = await db.redirects.findFirst({ where: { from: oldSlug }, });
if (newSlug) { permanentRedirect(`/blog/${newSlug.to}`); // 308 Permanent Redirect }}