В веб-приложениях корпоративного уровня управление состоянием форм исторически являлось одной из главных причин деградации производительности рендеринга. Классический подход React с контролируемыми компонентами (value + onChange), синхронизирующий каждый введенный символ в состояние компонента (useState), неизбежно вызывает каскадную перерисовку всего поддерева формы со сложностью по времени $O(N)$, где $N$ — количество смонтированных компонентов. С релизом React 19 и переходом на парадигму Server Functions (Actions), хук useActionState и нативную обработку FormData, архитектура взаимодействия клиентских форм и бэкенда претерпела фундаментальный сдвиг.
Библиотека React Hook Form v7+ решает задачу минимизации рендеров за счет неконтролируемых инпутов и подписки на изменения через JavaScript Proxy. Однако интеграция RHF с асинхронными Server Actions и хуком useActionState в React 19 требует построения гибридного моста, объединяющего мгновенную клиентскую валидацию, передачу нативных данных FormData и двустороннюю синхронизацию серверных ошибок мутации.
React Hook Form v7 не сохраняет значения полей в React State. Значения удерживаются непосредственно в DOM-узлах (HTMLInputElement.value), а RHF хранит ссылки на них через замыкания ref. Реактивные обновления UI (подсветка ошибок, флаги isSubmitting, isDirty) активируются строго по требованию через точечные селекторы Proxy-объекта formState.
Чтобы понять причины архитектурных сложностей при объединении React Hook Form и React 19 Actions, необходимо проанализировать два противоположных подхода к управлению жизненным циклом данных формы.
В классической парадигме React форма описывается как проекция состояния:
[Клавиатурный ввод] -> [DOM Event input] -> [React setState] -> [Fiber Re-render] -> [DOM Commit]При каждом нажатии клавиши:
onChange.dispatchAction).value записывается обратно в реальный DOM-узел.На формах с 50–100 полями (сложные CRM-системы, финансовые анкеты, конфигураторы) ввод символа приводит к задержкам ответа интерфейса (Interaction to Next Paint — INP) свыше 150–200 мс, вызывая визуальные подергивания и блокировку основного потока браузера.
React Hook Form использует инверсию контроля:
register('fieldName').addEventListener('change'), addEventListener('blur')) в зависимости от настроенного режима mode: 'onBlur' | 'onChange' | 'onSubmit'.fieldsRef) представляет собой мутируемый объект памяти (MutableRefObject), изолированный от Fiber-цикла React.errors.fieldName).+------------------------------------------------+
| React Hook Form Engine |
| |
[Native Input] |--(ref registration)--> [fieldsRef (Object in RAM)]
| | | |
(User types) | | (Validation) |
| | v |
v | [Internal State Machine] |
[DOM Updates] | | |
(Zero React | [Proxy Trap] <----------+ |
re-renders) +--------------|---------------------------------+
|
(Only if error changes)
v
[Target React Component]В React 19 на смену ручным обработчикам onSubmit={handleSubmit} с вызовами fetch() пришел стандарт Server Functions:
<form action={formAction}> принимает функцию-экшен.const [state, formAction, isPending] = useActionState(serverAction, initialState) перехватывает нативную отправку формы, упаковывает значения полей в объект FormData и запускает неблокирующий переход (startTransition).В чем возникает архитектурный конфликт?
FormData, извлекая значения по атрибуту name из DOM.handleSubmit((data) => ...).errors библиотеки RHF без рассинхронизации внутренних флагов isValid и isSubmitting.Интеграция React Hook Form с React 19 строится по схеме двухуровневого шлюза валидации:
startTransition, вызывая экшен useActionState.ActionState). При наличии серверных ошибок RHF подхватывает их через метод setError().+-----------------------------------------------------------------------------------+
| CLIENT BROWSER RUNTIME |
| |
| [User Click Submit] |
| | |
| v |
| [RHF: handleSubmit()] -----------------(Invalid)-----> [Update errors in Proxy] |
| | | |
| (Valid) v |
| v [Display Client Error]|
| [startTransition(() => formAction(payload))] |
| | |
| v (isPending = true) |
| [React 19 Action Transport: HTTP POST RPC] |
+----------|------------------------------------------------------------------------+
|
v
+-----------------------------------------------------------------------------------+
| SERVER RUNTIME (Node.js / Edge) |
| |
| ['use server' Server Action Entry] |
| | |
| v |
| [Zod/TypeBox Server Schema Parse] -----(Invalid)-----> [Return { serverErrors }] |
| | | |
| (Valid) | |
| v | |
| [DB Mutation: Prisma / Drizzle / SQL] | |
| | | |
| v | |
| [Return { success: true, data: result }] | |
+----------|------------------------------------------------------------|-----------+
| |
+----------------------------+-------------------------------+
|
v
+-----------------------------------------------------------------------------------+
| CLIENT BROWSER RESPONSE HANDLER |
| |
| [useActionState receives new state] |
| | |
| +---> If serverErrors: loop -> form.setError(field, { message }) |
| | |
| +---> If success: form.reset(newData) or router.push('/dashboard') |
| |
| (isPending = false) |
+-----------------------------------------------------------------------------------+Инпуты подключаются к RHF через callback-ref register() без передачи состояния в React. Свойства formState отслеживаются через Proxy.
Метод handleSubmit() блокирует нативную отправку, читает значения из fieldsRef и запускает валидатор (Zod Resolver).
При успехе клиентской валидации вызывается экшен из useActionState внутри startTransition, переводя UI в состояние isPending.
Server Action исполняет мутацию в БД. При падении бизнес-правил формируется типизированная карта ошибок полей { fieldName: errorText }.
Эффект или обработчик ответа обходит карту серверных ошибок и точечно инжектирует их в RHF через form.setError(name, { type: 'server' }).
Почему вызов formState.isDirty или деструктуризация всего объекта formState в теле компонента может ухудшить производительность формы?
Формы создания платежей и переводов требуют строгой клиентской валидации сумм (лимиты, регулярные выражения счетов) для предотвращения сетевого спама, а также серверной проверки лимитов антифрод-системы с возвратом точечных серверных ошибок при превышении дневного оборота.
Формы с 200+ параметрами оборудования, фильтрами спецификаций и переключателями. За счет использования register без перерендеров формы работают с нулевым дропом кадров (60 FPS / INP менее 16 мс) даже на мобильных терминалах с ограниченным CPU.
Формы подачи заявлений, способные корректно отправить FormData на сервер через Server Action при нестабильном 3G-соединении или заблокированном JS-бандле, но предоставляющие мгновенную подсказку типов при активном гидратированном RHF.
Синхронизация ошибок rate-limiting (429 Too Many Requests), неверных одноразовых TOTP-кодов и блокировок учетных записей прямо в дерево полей RHF через setError('totpCode', { type: 'server', message: 'Неверный код' }).
Разберем низкоуровневую реализацию моста между React Hook Form v7 и React 19 useActionState.
Спроектируем строгий контракт результата серверного действия (ActionState), поддерживающий типизацию ошибок полей:
// types/form-actions.ts
export type FieldErrors<T> = Partial<Record<keyof T, string[]>>;
export interface ActionResponse<TData, TInput> {
success: boolean;
data?: TData;
fieldErrors?: FieldErrors<TInput>;
formError?: string;
timestamp: number;
}На сервере мы полностью валидируем входные данные с помощью схемы Zod и возвращаем структуру ошибок, готовую для маппинга в RHF:
// actions/register-user.ts
'use server';
import { z } from 'zod';
import type { ActionResponse } from '@/types/form-actions';
const RegisterSchema = z.object({
email: z.string().email('Некорректный формат корпоративного email'),
username: z.string().min(3, 'Длина логина должна быть не менее 3 символов'),
organizationSlug: z.string().regex(/^[a-z0-9-]+$/, 'Только строчные латинские буквы и дефис'),
});
export type RegisterInput = z.infer<typeof RegisterSchema>;
export interface RegisterResult {
userId: string;
orgId: string;
}
export async function registerUserAction(
prevState: ActionResponse<RegisterResult, RegisterInput> | null,
payload: RegisterInput
): Promise<ActionResponse<RegisterResult, RegisterInput>> {
// 1. Серверная проверка схемы
const parsed = RegisterSchema.safeParse(payload);
if (!parsed.success) {
const flattened = parsed.error.flatten();
return {
success: false,
fieldErrors: flattened.fieldErrors as Partial<Record<keyof RegisterInput, string[]>>,
timestamp: Date.now(),
};
}
const { email, username, organizationSlug } = parsed.data;
// 2. Имитация бизнес-логики: проверка коллизий в БД
if (email === '[email protected]') {
return {
success: false,
fieldErrors: {
email: ['Данный email зарезервирован системной учетной записью'],
},
timestamp: Date.now(),
};
}
if (organizationSlug === 'forbidden') {
return {
success: false,
fieldErrors: {
organizationSlug: ['Идентификатор организации заблокирован политикой безопасности'],
},
timestamp: Date.now(),
};
}
// 3. Успешная мутация
return {
success: true,
data: {
userId: 'usr_' + Math.random().toString(36).substring(2, 9),
orgId: 'org_' + organizationSlug,
},
timestamp: Date.now(),
};
}Ниже представлен production-компонент, в котором:
useForm настраивается со схемой Zod для мгновенной клиентской проверки.useActionState управляет статусом isPending и серверным состоянием.handleSubmit перехватывает клик, валидирует форму на клиенте и передает типизированные данные в startTransition.useEffect отслеживает возвращаемые сервером fieldErrors и транслирует их в форму через setError.'use client';
import React, { useActionState, startTransition, useEffect, useTransition } from 'react';
import { useForm, type SubmitHandler } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { registerUserAction, type RegisterInput, type RegisterResult } from '@/actions/register-user';
import type { ActionResponse } from '@/types/form-actions';
// Клиентская схема валидации
const ClientRegisterSchema = z.object({
email: z.string().min(1, 'Email обязателен к заполнению').email('Некорректный email адрес'),
username: z.string().min(3, 'Минимум 3 символа').max(20, 'Максимум 20 символов'),
organizationSlug: z.string().min(2, 'Минимум 2 символа'),
});
export function EnterpriseRegistrationForm() {
// 1. Инициализация React 19 Action State
const [actionState, formAction, isServerPending] = useActionState<
ActionResponse<RegisterResult, RegisterInput> | null,
RegisterInput
>(registerUserAction, null);
const [isClientTransition, startClientTransition] = useTransition();
const isPending = isServerPending || isClientTransition;
// 2. Инициализация React Hook Form с неконтролируемыми инпутами
const {
register,
handleSubmit,
setError,
clearErrors,
reset,
formState: { errors, isValid, isDirty },
} = useForm<RegisterInput>({
resolver: zodResolver(ClientRegisterSchema),
mode: 'onBlur',
defaultValues: {
email: '',
username: '',
organizationSlug: '',
},
});
// 3. Синхронизация серверных ошибок с полями RHF
useEffect(() => {
if (!actionState) return;
if (!actionState.success && actionState.fieldErrors) {
// Итерируемся по полям ошибок, пришедшим из бэкенда
for (const [fieldName, errorMessages] of Object.entries(actionState.fieldErrors)) {
if (errorMessages && errorMessages.length > 0) {
setError(fieldName as keyof RegisterInput, {
type: 'server',
message: errorMessages[0],
});
}
}
}
if (actionState.success) {
// Сброс формы при успешной мутации
reset();
}
}, [actionState, setError, reset]);
// 4. Клиентский обработчик Submit с пробросом в Server Action
const onSubmit: SubmitHandler<RegisterInput> = (validData) => {
// Очищаем предыдущие ошибки сервера перед отправкой
clearErrors();
// Запускаем вызов Action внутри React 19 Transition
startClientTransition(() => {
startTransition(async () => {
await formAction(validData);
});
});
};
return (
<div className="max-w-md mx-auto p-6 bg-slate-900 border border-slate-800 rounded-xl shadow-xl">
<h2 className="text-xl font-bold text-white mb-4">Регистрация в кластере WPSKILLS</h2>
{actionState?.formError && (
<div className="mb-4 p-3 bg-red-950/60 border border-red-800 text-red-300 text-sm rounded-lg">
{actionState.formError}
</div>
)}
{actionState?.success && actionState.data && (
<div className="mb-4 p-3 bg-emerald-950/60 border border-emerald-800 text-emerald-300 text-sm rounded-lg">
Организация успешно создана: ID {actionState.data.orgId}
</div>
)}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
{/* Поле Email */}
<div>
<label htmlFor="reg-email" className="block text-xs font-semibold uppercase tracking-wider text-slate-300 mb-1">
Корпоративный Email
</label>
<input
id="reg-email"
type="email"
disabled={isPending}
className={`w-full px-3.5 py-2 bg-slate-950 border rounded-lg text-sm text-slate-100 placeholder-slate-500 focus:outline-none transition-colors ${
errors.email ? 'border-red-500 focus:border-red-400' : 'border-slate-800 focus:border-cyan-500'
}`}
placeholder="[email protected]"
{...register('email')}
/>
{errors.email && (
<p className="mt-1 text-xs text-red-400 font-mono">{errors.email.message}</p>
)}
</div>
{/* Поле Username */}
<div>
<label htmlFor="reg-username" className="block text-xs font-semibold uppercase tracking-wider text-slate-300 mb-1">
Имя пользователя (Unix slug)
</label>
<input
id="reg-username"
type="text"
disabled={isPending}
className={`w-full px-3.5 py-2 bg-slate-950 border rounded-lg text-sm text-slate-100 placeholder-slate-500 focus:outline-none transition-colors ${
errors.username ? 'border-red-500 focus:border-red-400' : 'border-slate-800 focus:border-cyan-500'
}`}
placeholder="architect"
{...register('username')}
/>
{errors.username && (
<p className="mt-1 text-xs text-red-400 font-mono">{errors.username.message}</p>
)}
</div>
{/* Поле Organization Slug */}
<div>
<label htmlFor="reg-org" className="block text-xs font-semibold uppercase tracking-wider text-slate-300 mb-1">
Идентификатор организации
</label>
<input
id="reg-org"
type="text"
disabled={isPending}
className={`w-full px-3.5 py-2 bg-slate-950 border rounded-lg text-sm text-slate-100 placeholder-slate-500 focus:outline-none transition-colors ${
errors.organizationSlug ? 'border-red-500 focus:border-red-400' : 'border-slate-800 focus:border-cyan-500'
}`}
placeholder="enterprise-corp"
{...register('organizationSlug')}
/>
{errors.organizationSlug && (
<p className="mt-1 text-xs text-red-400 font-mono">{errors.organizationSlug.message}</p>
)}
</div>
{/* Кнопка отправки */}
<button
id="reg-submit-btn"
type="submit"
disabled={isPending}
className="w-full py-2.5 px-4 rounded-lg bg-cyan-600 hover:bg-cyan-500 disabled:bg-slate-800 disabled:text-slate-500 text-white font-semibold text-sm transition-all duration-150 flex items-center justify-center gap-2 cursor-pointer disabled:cursor-not-allowed shadow-lg shadow-cyan-950/40"
>
{isPending ? (
<>
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
<span>Обработка транзакции...</span>
</>
) : (
<span>Зарегистрировать организацию</span>
)}
</button>
</form>
</div>
);
}Для нативных элементов (<input>, <textarea>, <select>) метод register() идеален: он возвращает { name, ref, onChange, onBlur }.
Однако компоненты современных UI-библиотек (Radix UI Select, React Aria Calendar, Slate Editor, React Select) не экспонируют нативный DOM <input>. Они инкапсулируют свое состояние внутри составного дерева (<div> + ARIA-атрибуты).
Для таких компонентов используется компонент <Controller> или хук useController:
import { Controller, type Control } from 'react-hook-form';
import * as Select from '@radix-ui/react-select';
interface ControlledSelectProps {
name: string;
control: Control<any>;
}
export function RoleSelector({ name, control }: ControlledSelectProps) {
return (
<Controller
name={name}
control={control}
render={({ field, fieldState }) => (
<div>
<Select.Root value={field.value} onValueChange={field.onChange}>
<Select.Trigger className="w-full p-2 border rounded">
<Select.Value placeholder="Выберите роль" />
</Select.Trigger>
<Select.Content>
<Select.Item value="admin">Администратор</Select.Item>
<Select.Item value="dev">Разработчик</Select.Item>
</Select.Content>
</Select.Root>
{fieldState.error && (
<span className="text-red-500 text-xs">{fieldState.error.message}</span>
)}
</div>
)}
/>
);
}Пользователь набирает символы в полях формы. Нативное значение обновляется в DOM-дереве. React Fiber не выполняет перерисовок.
fieldsRef хранит указатели на HTMLElement, не вызывая реактивных каскадов.
При нажатии Submit метод handleSubmit вызывает Zod Resolver. Если клиентская валидация провалена, рендерятся ошибки, сетевой запрос блокируется.
Экономит серверные ресурсы и обеспечивает нулевую задержку обратной связи для пользователя.
startTransition вызывает serverAction. Fiber помечает переход как low-priority, выставляя флаг isPending = true без блокировки UI.
Пользователь сохраняет возможность взаимодействовать со страницей (скролл, клики) во время выполнения запроса.
Node.js/Edge выполняет Server Action: повторная Zod-валидация, проверка сессии RBAC, транзакция в базе данных.
Возвращается сериализуемый объект ActionResponse с флагом success и возможной картой fieldErrors.
Хук useEffect считывает fieldErrors из actionState и вызывает setError(name, { type: 'server' }), точечно обновляя Proxy-подписчиков.
При следующем вводе в поле RHF автоматически снимает серверную ошибку, если настроен режим валидации.
Что произойдет, если сервер вернет ошибку для поля, которого физически нет в схеме RHF (например, глобальная ошибка 'tenant_locked'), и мы попытаемся вызвать setError('tenant_locked' as any)?
Самая частая ошибка разработчиков, переходящих на RHF:
// ❌ АНТИПАТТЕРН: Деструктуризация ломает ленивую Proxy-подписку
const { register, formState: { errors, isDirty, isValid, touchedFields } } = useForm();При таком синтаксисе JavaScript обращается ко всем указанным полям сразу при первом рендере. RHF фиксирует, что компонент «заинтересован» в isDirty, isValid и touchedFields. В результате каждое нажатие клавиши в любом инпуте будет вызывать полный рендеринг компонента формы, сводя на нет преимущества неконтролируемых инпутов.
// ✅ ПРАВИЛЬНО: Читать только то, что действительно нужно, либо изолировать в отдельные компоненты
const { register, formState } = useForm();
// Чтение formState.errors.email происходит только там, где оно отображаетсяЕсли сервер вернул ошибку поля (setError('email', { type: 'server', message: 'Email занят' })), RHF пометит форму как невалидную (isValid: false). При повторной отправке формы после исправления значения пользователем, если тип валидации не сбросил ошибку автоматически, форма может заблокироваться. Перед отправкой нового Server Action обязательно вызывайте clearErrors() или конфигурируйте shouldFocusError: true.
Использование <Controller> для стандартных <input type="text"> или <textarea>:
// ❌ ИЗБЫТОЧНО: Использование Controller для нативного элемента
<Controller
name="firstName"
control={control}
render={({ field }) => <input {...field} />}
/>
// ✅ ЭФФЕКТИВНО: Нативная регистрация ref
<input {...register('firstName')} />Controller создает дополнительную обертку и подписывается на события onChange, увеличивая расход оперативной памяти и время работы React Reconciler при монтировании больших списков полей.
Если передать formAction напрямую в <form action={formAction}>, минуя handleSubmit(onSubmit):
// ❌ ОШИБКА: Обход клиентской валидации RHF
<form action={formAction}>
<input {...register('email')} />
</form>Браузер выполнит отправку формы напрямую через React 19 Action, собрав данные из DOM. При этом клиентская схема Zod в RHF вообще не выполнится, поля с ошибками не получат стилизацию, а на сервер уйдут сырые невалидные данные. Связывание должно идти строго через onSubmit={handleSubmit(onSubmit)}.