Традиционный подход к мутации данных через Route Handlers (/api/...) страдает от разрыва типов, дублирования схем валидации и задержки отклика интерфейса (Perceived Latency). Архитектура Server Actions стирает границу между клиентом и бэкендом: функции выполняются строго на сервере, валидируют входящий поток через Zod DTO, управляют жизненным циклом отправки через нативный хук React 19 useActionState и устраняют визуальные задержки до нуля миллисекунд с помощью хука useOptimistic.
В высоконагруженных SaaS-сервисах и e-commerce приложениях паттерны мутации данных напрямую влияют на конверсию и стабильность системы:
route.ts, на клиенте писать вызов fetch, вручную менеджить стейты isLoading, error, data, а затем принудительно инвалидировать кэш сторонними библиотеками (React Query, SWR).Next-Action. Мутации без явной валидации прав внутри тела экшена открывают уязвимости несанкционированного доступа (BOLA / IDOR).Связка Server Actions + Zod + useActionState + useOptimistic + revalidatePath дает промышленный золотой стандарт: 100% типобезопасность от формы до базы данных, мгновенный отклик UI и корректная очистка кэша Full Route Cache.
Server Action компилируется Next.js в скрытый POST-маршрут с уникальным идентификатором. При его вызове клиент отправляет payload, а сервер возвращает ответ в формате потока React Flight (RSC Wire Format).
[ Client Component (useOptimistic) ]
│
├── 1. Мгновенная отрисовка: useOptimistic(tasks, (state, newTask) => [...state, newTask])
│ └─ Интерфейс обновляется за 0 мс!
│
├── 2. Фоновый вызов Server Action (Flight Protocol POST)
│ │ Заголовок: Next-Action: <action_hash>
│ │ Тело: FormData / JSON payload
│ ▼
[ Server Boundary ('use server') ]
│ │
│ ├── 3. Проверка сессии (Auth / RBAC check)
│ ├── 4. Валидация схемы: TaskSchema.safeParse(formData)
│ ├── 5. Мутация в БД: db.tasks.create(...)
│ └── 6. Инвалидация кэша: revalidatePath('/tasks')
│ ▼
[ React Flight Response ]
│ │
│ ├── Возврат результата ActionState { success: true, data: newTask }
│ └── Серверный RSC-дифф обновленной страницы (Selective Hydration)
▼
[ Client Synchronization ]
│
├── Успех: Оптимистичное состояние заменяется постоянным серверным
└── Ошибка: Автоматический откат к исходному снимку + вывод toastСоздание контракта DTO для валидации полей формы и универсального типа результата ActionResponse с поддержкой ошибок валидации по полям.
Создание изолированного файла actions.ts с директивой 'use server', вызовом safeParse, защитой от инъекций и вызовом revalidatePath.
Подключение серверного действия к React-форме с автоматическим получением флага ожидания isPending и ошибок валидации.
Мгновенное добавление/удаление сущностей в интерфейсе до получения ответа от сервера с гарантированным откатом при сбое.
Настройка точечного сброса кэша страницы через revalidatePath и обеспечение базовой работы формы даже при отключенном JS.
Пользователь отправляет форму или кликает на кнопку быстрого действия
Клиент немедленно вставляет временный объект со статусом pending: true
Браузер отправляет асинхронный POST-запрос с заголовком Next-Action
Zod валидирует DTO, ORM выполняет транзакцию, revalidatePath очищает кэш
Next.js генерирует свежий RSC-снимок страницы и отправляет его клиенту
Временный оптимистичный узел плавно подтверждается постоянным ID из базы данных
В чем заключается принципиальная разница между вызовом revalidatePath('/posts') внутри Server Action и вызовом router.refresh() на клиенте после успешной мутации?
Каким образом React 19 определяет момент, когда необходимо отменить оптимистичное состояние useOptimistic при ошибке сетевого запроса Server Action?
src/lib/validations/post-schema.ts#Строгая типизация входящих данных, сообщений об ошибках и универсальный тип ответа Server Action.
/**
* src/lib/validations/post-schema.ts
* Схемы валидации DTO для сущности Post и контракты ответов экшенов.
*/
import { z } from 'zod'
// Схема создания новой записи
export const CreatePostSchema = z.object({
title: z
.string()
.trim()
.min(3, 'Заголовок должен содержать не менее 3 символов')
.max(120, 'Заголовок не может превышать 120 символов'),
content: z
.string()
.trim()
.min(10, 'Содержимое статьи должно быть не короче 10 символов')
.max(5000, 'Содержимое превышает лимит в 5000 символов'),
category: z.enum(['frontend', 'backend', 'devops', 'architecture'], {
errorMap: () => ({ message: 'Выберите корректную категорию из списка' }),
}),
})
export type CreatePostInput = z.infer<typeof CreatePostSchema>
// Модель сущности в базе данных
export interface PostEntity {
id: string
title: string
content: string
category: 'frontend' | 'backend' | 'devops' | 'architecture'
published: boolean
createdAt: string
}
// Универсальный контракт результата выполнения Server Action
export type ActionResponse<T = unknown> = {
success: boolean
message?: string
data?: T
errors?: Record<string, string[]>
}src/actions/post-actions.ts#Промышленная реализация CRUD экшенов с директивой 'use server', валидацией, авторизацией и revalidatePath.
'use server'
import { revalidatePath } from 'next/cache'
import { CreatePostSchema, ActionResponse, PostEntity } from '@/lib/validations/post-schema'
// Временное in-memory хранилище для демонстрации (в продакшене заменяется на Prisma / Drizzle / PostgreSQL)
let POSTS_DB: PostEntity[] = [
{
id: 'post_1',
title: 'Архитектура React 19: Server Actions и Concurrent Features',
content: 'Полный обзор фундаментальных примитивов React 19 и Next.js 15.',
category: 'frontend',
published: true,
createdAt: new Date().toISOString(),
},
{
id: 'post_2',
title: 'Оптимизация базы данных MariaDB для высоконагруженных сайтов',
content: 'Индексы, транзакции, пулы соединений и кэширование запросов.',
category: 'backend',
published: false,
createdAt: new Date().toISOString(),
},
]
/**
* Получение списка постов (для серверных компонентов RSC).
*/
export async function getPosts(): Promise<PostEntity[]> {
// Имитация минимальной задержки чтения
await new Promise((resolve) => setTimeout(resolve, 50))
return [...POSTS_DB].sort((a, b) => b.createdAt.localeCompare(a.createdAt))
}
/**
* Серверное действие: Создание нового поста с поддержкой useActionState.
*/
export async function createPostAction(
_prevState: ActionResponse<PostEntity>,
formData: FormData
): Promise<ActionResponse<PostEntity>> {
// Имитация аутентификации пользователя на сервере
const sessionToken = formData.get('sessionToken')
// В реальном приложении: const session = await auth(); if (!session) ...
const rawData = {
title: formData.get('title'),
content: formData.get('content'),
category: formData.get('category'),
}
// Строгая валидация DTO через safeParse
const validationResult = CreatePostSchema.safeParse(rawData)
if (!validationResult.success) {
return {
success: false,
message: 'Ошибка валидации входящих данных формы',
errors: validationResult.error.flatten().fieldErrors,
}
}
try {
// Имитация сетевой задержки записи в БД
await new Promise((resolve) => setTimeout(resolve, 400))
const newPost: PostEntity = {
id: `post_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
...validationResult.data,
published: true,
createdAt: new Date().toISOString(),
}
POSTS_DB.push(newPost)
// Инвалидация серверного кэша страницы со списком постов
revalidatePath('/posts')
return {
success: true,
message: 'Статья успешно опубликована!',
data: newPost,
}
} catch (error) {
console.error('[createPostAction Error]:', error)
return {
success: false,
message: 'Непредвиденная ошибка базы данных при сохранении статьи',
}
}
}
/**
* Серверное действие: Переключение статуса публикации (toggle).
*/
export async function togglePostPublishAction(postId: string): Promise<ActionResponse<PostEntity>> {
try {
await new Promise((resolve) => setTimeout(resolve, 300))
const post = POSTS_DB.find((p) => p.id === postId)
if (!post) {
return { success: false, message: 'Статья не найдена' }
}
post.published = !post.published
revalidatePath('/posts')
return { success: true, data: post }
} catch {
return { success: false, message: 'Сбой переключения статуса' }
}
}
/**
* Серверное действие: Удаление поста.
*/
export async function deletePostAction(postId: string): Promise<ActionResponse<{ id: string }>> {
try {
await new Promise((resolve) => setTimeout(resolve, 350))
POSTS_DB = POSTS_DB.filter((p) => p.id !== postId)
revalidatePath('/posts')
return { success: true, data: { id: postId } }
} catch {
return { success: false, message: 'Не удалось удалить запись' }
}
}src/components/posts/optimistic-post-list.tsx#Клиентский компонент со сверхбыстрым UI-откликом на базе хука useOptimistic.
'use client'
import * as React from 'react'
import { useOptimistic, startTransition } from 'react'
import { PostEntity } from '@/lib/validations/post-schema'
import { togglePostPublishAction, deletePostAction } from '@/actions/post-actions'
interface OptimisticPostListProps {
initialPosts: PostEntity[]
}
type OptimisticAction =
| { type: 'delete'; id: string }
| { type: 'toggle'; id: string }
| { type: 'add'; post: PostEntity }
export function OptimisticPostList({ initialPosts }: OptimisticPostListProps) {
// useOptimistic возвращает оптимистичное состояние и функцию-диспетчер
const [optimisticPosts, setOptimisticPosts] = useOptimistic(
initialPosts,
(state: PostEntity[], action: OptimisticAction) => {
switch (action.type) {
case 'delete':
return state.filter((p) => p.id !== action.id)
case 'toggle':
return state.map((p) =>
p.id === action.id ? { ...p, published: !p.published } : p
)
case 'add':
return [action.post, ...state]
default:
return state
}
}
)
const handleDelete = (id: string) => {
// startTransition обязателен для синхронизации useOptimistic с асинхронным экшеном
startTransition(async () => {
// 1. Мгновенно удаляем из интерфейса (0 мс)
setOptimisticPosts({ type: 'delete', id })
// 2. Отправляем сетевой вызов
const result = await deletePostAction(id)
if (!result.success) {
alert(result.message || 'Ошибка удаления записи')
}
})
}
const handleToggle = (id: string) => {
startTransition(async () => {
// 1. Мгновенно меняем бейдж статуса (0 мс)
setOptimisticPosts({ type: 'toggle', id })
// 2. Отправляем сетевой вызов
const result = await togglePostPublishAction(id)
if (!result.success) {
alert(result.message || 'Не удалось обновить статус')
}
})
}
return (
<div className="space-y-3">
<h2 className="text-lg font-bold text-text-primary">
Все статьи ({optimisticPosts.length})
</h2>
{optimisticPosts.length === 0 ? (
<p className="text-text-secondary text-sm py-4">Список статей пуст.</p>
) : (
<div className="divide-y divide-border rounded-lg border border-border bg-surface">
{optimisticPosts.map((post) => (
<div
key={post.id}
className="flex items-center justify-between p-4 transition-colors hover:bg-surface-hover"
>
<div className="space-y-1">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-text-primary text-base">
{post.title}
</h3>
<span
className={`px-2 py-0.5 text-xs font-medium rounded-full ${
post.published
? 'bg-emerald-500/10 text-emerald-500 border border-emerald-500/20'
: 'bg-amber-500/10 text-amber-500 border border-amber-500/20'
}`}
>
{post.published ? 'Опубликовано' : 'Черновик'}
</span>
</div>
<p className="text-sm text-text-secondary line-clamp-1">
{post.content}
</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => handleToggle(post.id)}
className="px-2.5 py-1 text-xs font-medium rounded bg-surface border border-border hover:border-primary/50 text-text-primary transition-colors"
>
{post.published ? 'В черновик' : 'Опубликовать'}
</button>
<button
type="button"
onClick={() => handleDelete(post.id)}
className="px-2.5 py-1 text-xs font-medium rounded bg-rose-500/10 text-rose-500 border border-rose-500/20 hover:bg-rose-500/20 transition-colors"
>
Удалить
</button>
</div>
</div>
))}
</div>
)}
</div>
)
}src/components/posts/create-post-form.tsx#Форма создания статьи с нативным хуком useActionState, отображением ошибок по полям и isPending.
'use client'
import * as React from 'react'
import { useActionState } from 'react'
import { createPostAction } from '@/actions/post-actions'
import { ActionResponse, PostEntity } from '@/lib/validations/post-schema'
const initialActionState: ActionResponse<PostEntity> = {
success: false,
}
export function CreatePostForm() {
// useActionState заменяет устаревший useFormState из React 18
const [state, formAction, isPending] = useActionState(createPostAction, initialActionState)
const formRef = React.useRef<HTMLFormElement>(null)
// Очистка полей при успешной отправке
React.useEffect(() => {
if (state.success) {
formRef.current?.reset()
}
}, [state.success])
return (
<form
ref={formRef}
action={formAction}
className="space-y-4 rounded-xl border border-border bg-surface p-5 shadow-sm max-w-xl"
>
<div className="flex items-center justify-between border-b border-border pb-3">
<h3 className="font-bold text-base text-text-primary">
Добавить новую публикацию
</h3>
{isPending && (
<span className="flex items-center gap-1.5 text-xs text-primary font-medium">
<span className="h-2 w-2 rounded-full bg-primary animate-ping" />
Сохранение...
</span>
)}
</div>
{/* Вывод глобального алерта */}
{state.message && (
<div
className={`p-3 rounded-lg text-sm font-medium ${
state.success
? 'bg-emerald-500/10 text-emerald-500 border border-emerald-500/20'
: 'bg-rose-500/10 text-rose-500 border border-rose-500/20'
}`}
>
{state.message}
</div>
)}
{/* Поле: Заголовок */}
<div className="space-y-1">
<label htmlFor="title" className="block text-sm font-medium text-text-primary">
Заголовок статьи
</label>
<input
id="title"
name="title"
type="text"
placeholder="Например: Полное руководство по Server Actions"
className="w-full px-3 py-2 text-sm bg-surface border border-border rounded-lg outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 text-text-primary"
required
/>
{state.errors?.title && (
<p className="text-xs text-rose-500 font-medium">
{state.errors.title.join(', ')}
</p>
)}
</div>
{/* Поле: Категория */}
<div className="space-y-1">
<label htmlFor="category" className="block text-sm font-medium text-text-primary">
Категория
</label>
<select
id="category"
name="category"
defaultValue="frontend"
className="w-full px-3 py-2 text-sm bg-surface border border-border rounded-lg outline-none focus:border-primary text-text-primary"
>
<option value="frontend">Frontend (React / Next.js)</option>
<option value="backend">Backend (Node.js / Go)</option>
<option value="devops">DevOps & CI/CD</option>
<option value="architecture">Архитектура систем</option>
</select>
{state.errors?.category && (
<p className="text-xs text-rose-500 font-medium">
{state.errors.category.join(', ')}
</p>
)}
</div>
{/* Поле: Содержимое */}
<div className="space-y-1">
<label htmlFor="content" className="block text-sm font-medium text-text-primary">
Текст публикации
</label>
<textarea
id="content"
name="content"
rows={4}
placeholder="Краткое изложение архитектурных решений..."
className="w-full px-3 py-2 text-sm bg-surface border border-border rounded-lg outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 text-text-primary"
required
/>
{state.errors?.content && (
<p className="text-xs text-rose-500 font-medium">
{state.errors.content.join(', ')}
</p>
)}
</div>
{/* Кнопка отправки с защитой от двойного клика */}
<button
type="submit"
disabled={isPending}
className="w-full flex items-center justify-center gap-2 py-2 px-4 rounded-lg bg-primary text-white text-sm font-semibold hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-primary/40 disabled:opacity-50 transition-opacity"
>
{isPending ? 'Публикация статьи...' : 'Опубликовать статью'}
</button>
</form>
)
}// ОШИБКА: прямое сохранение нетипизированного formData в базу данных
export async function badAction(formData: FormData) {
'use server'
const title = formData.get('title') as string
await db.posts.create({ data: { title } }) // Уязвимость SQLi / NoSQL injection / пустые строки
}
// ПРАВИЛЬНО: валидация через safeParse
export async function goodAction(formData: FormData) {
'use server'
const result = PostSchema.safeParse(Object.fromEntries(formData))
if (!result.success) return { success: false, errors: result.error.flatten() }
await db.posts.create({ data: result.data })
}revalidatePath или revalidateTag#Если после мутации в базе данных экшен не вызывает revalidatePath(), сервер Next.js вернет клиенту устаревший кэшированный снимок страницы. Пользователю покажется, что его изменение не сохранилось, что вызовет повторные клики.
startTransition при ручном триггере#Если экшен вызывается вне <form action={...}> (например, в обычном onClick={() => deleteItem()}), оборачивание вызова в startTransition обязательно для корректной работы индикаторов isPending и хука useOptimistic.