Создание отказоустойчивых, масштабируемых и полностью доступных (WAI-ARIA) динамических форм корпоративного уровня. Разбираем архитектуру изолированных подписок полей без глобальных ререндеров дерева, строгий контракт схем Zod, управление динамическими массивами сущностей через useFieldArray и бесшовную интеграцию headless-компонентов Radix UI.
В enterprise-приложениях формы часто превращаются в основной источник проблем с производительностью и стабильностью:
useState на уровне родительской формы приводит к повторному рендерингу всей страницы при каждом нажатом символе в поле ввода.aria-invalid, aria-describedby, связка с FormMessage) нарушают стандарты WCAG 2.2 AA.Архитектура связки React Hook Form (RHF) + Zod + Radix UI решает эти задачи на уровне ядра: RHF использует неконтролируемые инпуты с подписками на рефы, Zod гарантирует сквозную типизацию через z.infer, а Radix Primitives обеспечивают идеальную клавиатурную навигацию и доступность для скринридеров.
В основе высокой производительности React Hook Form лежит архитектура изолированного издателя-подписчика (Pub/Sub Observer). Вместо синхронизации каждого символа в общем стейте React-компонента, форма оперирует нативными DOM-рефами, а ререндерятся только те микро-компоненты, которые явно запросили состояние валидации или значение конкретного поля:
+-------------------------------------------------------------------------------+
| HTML FORM CONTAINER |
| |
| +-----------------------+ +--------------------------------+ |
| | Native DOM Input | | Radix UI Custom Component | |
| | (Uncontrolled Ref) | | (Controlled Controller) | |
| +-----------+-----------+ +---------------+----------------+ |
| | (native change) | (field.onChange) |
| v v |
| +-----------------------------------------------------------------------+ |
| | React Hook Form Core State Engine | |
| | - Proxy Observer State (isDirty, isValid, touched) | |
| +-----------------------------------+-----------------------------------+ |
| | |
| v (validate schema) |
| +-----------------------------------------------------------------------+ |
| | Zod Schema Validation Engine | |
| | SafeParse(values) -> { success: true, data: TypedDTO } | |
| +-----------------------------------+-----------------------------------+ |
| | (broadcast field error) |
| v |
| +-----------------------------------------------------------------------+ |
| | Micro-Subscriber Component (<FormMessage />) | |
| | Изолированный ререндер ТОЛЬКО текстовой ошибки под полем! | |
| +-----------------------------------------------------------------------+ |
+-------------------------------------------------------------------------------+Декларативное описание схемы данных с кастомными валидаторами, вложенными массивами z.array() и автоматическим выводом типа DTO через z.infer.
Подключение хука useForm со стратегией валидации onTouched / onChange и строгими дефолтными значениями defaultValues для предотвращения hydration mismatch.
Подключение контроллера динамических массивов сущностей с обязательным использованием field.id в качестве React-ключа.
Интеграция селектов, чекбоксов и свитчей через компонент Controller с корректным пробросом ref, value и обработчиков aria-атрибутов.
Бесшовная передача валидированных данных в Server Action с повторной серверной проверкой Zod и возвратом серверных ошибок полей.
Почему при рендеринге динамического массива полей через useFieldArray категорически запрещено использовать индекс массива (index) в качестве атрибута key={index}?
Ниже представлена эталонная production-реализация динамической формы оформления заказа с динамическим добавлением позиций товаров, кастомным Radix-селектом категории и строгой валидацией Zod.
'use client';
import React from 'react';
import { useForm, useFieldArray, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import * as Select from '@radix-ui/react-select';
import { Plus, Trash2, Check, ChevronDown, AlertCircle } from 'lucide-react';
// 1. Схема валидации Zod с вложенным динамическим массивом
export const orderItemSchema = z.object({
productId: z.string().min(1, 'Выберите категорию товара'),
quantity: z.number().int().min(1, 'Минимум 1 шт.').max(99, 'Максимум 99 шт.'),
price: z.number().positive('Цена должна быть больше нуля'),
comment: z.string().max(120, 'Комментарий не более 120 символов').optional()
});
export const orderFormSchema = z.object({
customerName: z.string().min(2, 'Имя должно содержать от 2 символов'),
customerEmail: z.string().email('Некорректный email адрес'),
deliveryPriority: z.enum(['standard', 'express', 'overnight']),
items: z.array(orderItemSchema).min(1, 'Добавьте хотя бы одну позицию заказа')
});
export type OrderFormValues = z.infer<typeof orderFormSchema>;
interface OrderDynamicFormProps {
onSubmitAction: (data: OrderFormValues) => Promise<{ success: boolean; error?: string }>;
}
export function OrderDynamicForm({ onSubmitAction }: OrderDynamicFormProps) {
const [serverError, setServerError] = React.useState<string | null>(null);
const {
register,
control,
handleSubmit,
formState: { errors, isSubmitting }
} = useForm<OrderFormValues>({
resolver: zodResolver(orderFormSchema),
defaultValues: {
customerName: '',
customerEmail: '',
deliveryPriority: 'standard',
items: [{ productId: '', quantity: 1, price: 100, comment: '' }]
},
mode: 'onTouched'
});
const { fields, append, remove } = useFieldArray({
control,
name: 'items'
});
const onFormSubmit = async (data: OrderFormValues) => {
try {
setServerError(null);
const res = await onSubmitAction(data);
if (!res.success) {
setServerError(res.error || 'Ошибка сохранения заказа');
}
} catch (err: unknown) {
const errMsg = err instanceof Error ? err.message : 'Непредвиденный сбой';
setServerError(errMsg);
}
};
return (
<form onSubmit={handleSubmit(onFormSubmit)} className="space-y-8 bg-bg-surface p-6 rounded-2xl border border-border">
{serverError && (
<div className="flex items-center gap-3 p-4 bg-red-500/10 border border-red-500/30 rounded-xl text-red-400 text-sm">
<AlertCircle size={18} />
<span>{serverError}</span>
</div>
)}
{/* Основные контактные данные */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label htmlFor="customerName" className="block text-sm font-medium text-text-secondary mb-1.5">
Имя клиента
</label>
<input
id="customerName"
{...register('customerName')}
aria-invalid={!!errors.customerName}
className="w-full px-4 py-2.5 rounded-xl bg-bg-base border border-border focus:border-accent-violet outline-none transition"
placeholder="Александр Иванов"
/>
{errors.customerName && (
<p className="mt-1 text-xs text-red-400">{errors.customerName.message}</p>
)}
</div>
<div>
<label htmlFor="customerEmail" className="block text-sm font-medium text-text-secondary mb-1.5">
Электронная почта
</label>
<input
id="customerEmail"
type="email"
{...register('customerEmail')}
aria-invalid={!!errors.customerEmail}
className="w-full px-4 py-2.5 rounded-xl bg-bg-base border border-border focus:border-accent-violet outline-none transition"
placeholder="[email protected]"
/>
{errors.customerEmail && (
<p className="mt-1 text-xs text-red-400">{errors.customerEmail.message}</p>
)}
</div>
</div>
{/* Динамический блок позиций */}
<div className="space-y-4">
<div className="flex items-center justify-between border-b border-border pb-3">
<h3 className="text-base font-semibold text-text-primary">
Позиции спецификации ({fields.length})
</h3>
<button
type="button"
onClick={() => append({ productId: '', quantity: 1, price: 50, comment: '' })}
className="inline-flex items-center gap-2 px-3.5 py-1.5 rounded-lg bg-accent-violet/10 text-accent-violet hover:bg-accent-violet/20 text-xs font-medium transition"
>
<Plus size={14} /> Добавить позицию
</button>
</div>
{errors.items?.root && (
<p className="text-xs text-red-400">{errors.items.root.message}</p>
)}
{fields.map((field, index) => (
<div
key={field.id} // КРИТИЧНО: используем field.id вместо index!
className="p-4 rounded-xl bg-bg-base border border-border space-y-4 relative group"
>
<div className="grid grid-cols-1 md:grid-cols-12 gap-4 items-start">
{/* Radix UI Select интеграция */}
<div className="md:col-span-5">
<label className="block text-xs font-medium text-text-secondary mb-1">
Категория товара #{index + 1}
</label>
<Controller
control={control}
name={`items.${index}.productId`}
render={({ field: selectField }) => (
<Select.Root
value={selectField.value}
onValueChange={selectField.onChange}
>
<Select.Trigger
className="w-full flex items-center justify-between px-3.5 py-2 rounded-lg bg-bg-surface border border-border text-sm text-text-primary outline-none focus:border-accent-violet"
>
<Select.Value placeholder="Выберите позицию..." />
<Select.Icon>
<ChevronDown size={14} className="text-text-secondary" />
</Select.Icon>
</Select.Trigger>
<Select.Portal>
<Select.Content className="overflow-hidden bg-bg-surface border border-border rounded-xl shadow-2xl z-50">
<Select.Viewport className="p-1.5">
<Select.Item
value="hw-server"
className="flex items-center justify-between px-3 py-2 text-sm text-text-primary rounded-lg cursor-pointer hover:bg-accent-violet/15 outline-none"
>
<Select.ItemText>Серверное оборудование</Select.ItemText>
<Select.ItemIndicator><Check size={14} className="text-accent-violet" /></Select.ItemIndicator>
</Select.Item>
<Select.Item
value="sw-license"
className="flex items-center justify-between px-3 py-2 text-sm text-text-primary rounded-lg cursor-pointer hover:bg-accent-violet/15 outline-none"
>
<Select.ItemText>Лицензия ПО Enterprise</Select.ItemText>
<Select.ItemIndicator><Check size={14} className="text-accent-violet" /></Select.ItemIndicator>
</Select.Item>
<Select.Item
value="cloud-support"
className="flex items-center justify-between px-3 py-2 text-sm text-text-primary rounded-lg cursor-pointer hover:bg-accent-violet/15 outline-none"
>
<Select.ItemText>SLA Поддержка 24/7</Select.ItemText>
<Select.ItemIndicator><Check size={14} className="text-accent-violet" /></Select.ItemIndicator>
</Select.Item>
</Select.Viewport>
</Select.Content>
</Select.Portal>
</Select.Root>
)}
/>
{errors.items?.[index]?.productId && (
<p className="mt-1 text-xs text-red-400">
{errors.items[index]?.productId?.message}
</p>
)}
</div>
{/* Количество */}
<div className="md:col-span-3">
<label className="block text-xs font-medium text-text-secondary mb-1">
Количество
</label>
<input
type="number"
{...register(`items.${index}.quantity`, { valueAsNumber: true })}
className="w-full px-3 py-2 rounded-lg bg-bg-surface border border-border text-sm text-text-primary outline-none focus:border-accent-violet"
/>
{errors.items?.[index]?.quantity && (
<p className="mt-1 text-xs text-red-400">
{errors.items[index]?.quantity?.message}
</p>
)}
</div>
{/* Цена за единицу */}
<div className="md:col-span-3">
<label className="block text-xs font-medium text-text-secondary mb-1">
Цена ($)
</label>
<input
type="number"
{...register(`items.${index}.price`, { valueAsNumber: true })}
className="w-full px-3 py-2 rounded-lg bg-bg-surface border border-border text-sm text-text-primary outline-none focus:border-accent-violet"
/>
{errors.items?.[index]?.price && (
<p className="mt-1 text-xs text-red-400">
{errors.items[index]?.price?.message}
</p>
)}
</div>
{/* Кнопка удаления позиции */}
<div className="md:col-span-1 pt-6 flex justify-end">
<button
type="button"
disabled={fields.length === 1}
onClick={() => remove(index)}
className="p-2 text-text-secondary hover:text-red-400 disabled:opacity-30 disabled:pointer-events-none transition"
title="Удалить строку"
>
<Trash2 size={16} />
</button>
</div>
</div>
</div>
))}
</div>
<button
type="submit"
disabled={isSubmitting}
className="w-full py-3.5 px-6 rounded-xl font-medium text-white bg-accent-violet hover:bg-accent-violet/90 transition shadow-lg shadow-accent-violet/20 disabled:opacity-50"
>
{isSubmitting ? 'Проверка и оформление заказа...' : 'Подтвердить заказ'}
</button>
</form>
);
}В стандартных HTML инпутах <input type="number"> значение в DOM всегда является строкой (string). Если регистрировать поле через {...register('price')}, в Zod попадет строковое значение, что приведет к ошибке валидации схемы Expected number, received string. Всегда передавайте опцию { valueAsNumber: true } или используйте z.coerce.number().
Компоненты Radix UI (например, Select.Root или Switch.Root) не являются нативными HTML-элементами и не имеют прямого свойства target.value. Попытка навесить ref={register('field').ref} без Controller приведет к сбою или отсутствию вызова onChange. Интеграция любых headless-библиотек обязана выполняться через <Controller control={control} name="..." render={...} />.
Попытка вызывать reset() синхронно внутри useEffect при смене входных пропсов может спровоцировать бесконечный цикл ререндеров или мерцание ошибок валидации. В Next.js 16 и React 19 для инициализации формы новыми асинхронными данными используйте values prop хука useForm({ values: serverData }), который безопасно обновляет форму без лишних каскадных эффектов.
Как наиболее надежно обработать ошибки валидации Zod, возникшие на сервере внутри Server Action, и связать их с полями React Hook Form?