В корпоративных приложениях (ERP, логистических центрах, финансовых биллингах и системах управления цепочками поставок) типовой задачей является создание табличных редакторов с динамическим набором строк: заказы с сотнями товарных позиций, матричные калькуляторы скидок, сметы строительных проектов и формы бухгалтерских проводок. Реализация таких интерфейсов на базе стандартного состояния React (useState<Row[]>) приводит к катастрофической деградации производительности: при наличии 200 строк и 5 колонок (1000 полей ввода) ввод одного символа в любой ячейке инициирует рекурсивную перерисовку тысячи компонентов React Fiber, замораживая интерфейс на 200–500 мс.
Хук useFieldArray из библиотеки React Hook Form v7 решает задачу управления динамическими коллекциями полей за счет изоляции мутаций и уникального механизма сопоставления идентификаторов. Однако для масштабирования формы до 500+ строк требуется глубокое понимание внутреннего реестра указателей fieldsRef, точечной подписки через useWatch и архитектуры Drag-and-drop перемещения строк без сброса фокуса и состояния валидации.
При рендеринге динамических массивов полей категорически запрещено использовать index массива в качестве React key.
useFieldArray генерирует для каждой строки стабильный внутренний UUID-идентификатор field.id.
key={field.id}) для предотвращения десинхронизации DOM-инпутов.Чтобы понять, как useFieldArray оптимизирует работу с таблицами, рассмотрим физику сбоя стандартного рендеринга React при использовании индексов массивов.
Представим форму со списком инпутов, где в качестве ключа передан индекс элемента:
// ❌ КАТАСТРОФИЧЕСКИЙ АНТИПАТТЕРН: key={index}
{fields.map((item, index) => (
<tr key={index}>
<td><input {...register(`items.${index}.name`)} /></td>
<td><button onClick={() => remove(index)}>Удалить</button></td>
</tr>
))}Предположим, в таблице три строки: Строка 0 (Значение: "A"), Строка 1 (Значение: "B"), Строка 2 (Значение: "C").
Пользователь нажимает кнопку «Удалить» на Строке 1 (значение "B").
Что делает алгоритм Reconciliation в React Fiber:
key=0 -> Новый key=0: React считает, что компонент не изменился, и оставляет его как есть.key=1 -> Новый key=1: React считает, что узел сохранился, и оставляет физический DOM-узел старой строки 1!key=2 отсутствует в новом дереве: React размонтирует и удаляет из DOM физический узел Строки 2 (где находилось значение "C")!ref в React Hook Form указывают на некорректные DOM-элементы.useFieldArray при инициализации и вызове методов append, prepend, insert присваивает каждому объекту неизменяемое свойство id (например, id: "e5b7a120-94d1-4f76"):
[Исходный массив]
- { id: "uuid-1", name: "A" } -> React Key: "uuid-1" (DOM Node #1)
- { id: "uuid-2", name: "B" } -> React Key: "uuid-2" (DOM Node #2)
- { id: "uuid-3", name: "C" } -> React Key: "uuid-3" (DOM Node #3)
[Пользователь удаляет uuid-2]
- { id: "uuid-1", name: "A" } -> React Key: "uuid-1" (Сохранен)
- { id: "uuid-3", name: "C" } -> React Key: "uuid-3" (Перемещен вверх)
React Reconciliation:
Узел с key="uuid-2" физически удаляется из DOM.
Узел с key="uuid-3" сохраняет свои DOM-ссылки, фокус и валидационное состояние!Архитектура высокопроизводительной матричной формы разделяет обязанности на три независимых слоя:
useFieldArray. Контейнер перерисовывается только при изменении количества строк (добавление, удаление, перемещение).React.memo. Получает индекс и field.id. Предотвращает каскадный рендеринг соседних строк при редактировании текущей.Итого = Количество * Цена) подписывается на конкретные поля через хук useWatch({ name: 'items.' + index + '.price' }). При изменении цены перерисовывается исключительно одна ячейка «Итого», а не вся строка или таблица.+-------------------------------------------------------------------------------+
| TABLE CONTAINER (useFieldArray) |
| Rerenders ONLY on: append, remove, move, swap (Length change) |
| |
| +-------------------------------------------------------------------------+ |
| | ROW COMPONENT (React.memo, key={field.id}) | |
| | | |
| | [Input: Name] [Input: Price] [Input: Qty] [Cell: Subtotal] | |
| | (Uncontrolled ref) (Uncontrolled) (Uncontrolled) (useWatch Cell) | |
| | | | |
| | (User types here) | | |
| | | | | |
| | +---(Native DOM Input event: 0 Fiber Rerenders) | | |
| | | | | |
| | +---(useWatch notifies Subtotal Cell) --------------> | | |
| | (Only 1 Cell | |
| | rerenders!) | |
| +-------------------------------------------------------------------------+ |
| |
| +-------------------------------------------------------------------------+ |
| | ROW COMPONENT #2 (React.memo, key={field.id}) | |
| | ZERO rerenders during Row #1 editing! | |
| +-------------------------------------------------------------------------+ |
+-------------------------------------------------------------------------------+Метод append() создает новую запись, генерирует уникальный field.id и регистрирует поля в fieldsRef.
Пользователь вводит символы в ячейки. Значения сохраняются в DOM-узлах без вызова Fiber Reconciliation таблицы.
useWatch перехватывает изменения цены/количества и обновляет только компонент формулы через изолированный рендер.
Метод move(from, to) меняет указатели в fieldsRef и переставляет DOM-узлы без сброса ошибок и повторного монтирования.
handleSubmit собирает вложенный массив объектов из fieldsRef, валидирует схему Zod/Valibot и передает данные на сервер.
Почему при итерации по fields.map((field, index) => ...) в качестве пропса key необходимо передавать field.id, а не index или field.name?
Бухгалтерские и логистические спецификации с сотнями строк товарных позиций. Пользователь может вставлять строки из буфера обмена (Excel Copy-Paste), менять порядок перетаскиванием мыши и мгновенно видеть суммы по категориям без подергиваний скролла.
Сложные матрицы прав доступа (Пользователи x Роли x Ресурсы x Права чтения/записи/удаления). Каждая строка представляет роль, а колонки — чекбоксы доступа к микросервисам с возможностью массового включения/отключения («Отметить все в колонке»).
Конструкторы онлайн-опросов, схем данных и вложенных каталогов товаров: категория -> подкатегории -> модификаторы цен. Реализуется через иерархическую композицию вложенных useFieldArray.
Интерфейсы складского учета, где оператор применяет наценку 15% ко всем выбранным 300 товарам нажатием одной кнопки. Метод replace() производит атомарную замену массива значений в памяти за менее 5 мс.
Разработаем промышленную спецификацию заказа с расчетом промежуточных итогов, валидацией схемы Zod и полной изоляцией рендеринга строк:
'use client';
import React, { useId } from 'react';
import {
useForm,
useFieldArray,
useWatch,
type Control,
type UseFormRegister
} from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
// 1. Схема валидации строки и таблицы в Zod
const InvoiceItemSchema = z.object({
sku: z.string().min(2, 'SKU обязателен'),
description: z.string().min(3, 'Описание обязательно'),
quantity: z.number().int().positive('Минимум 1 шт.'),
unitPrice: z.number().positive('Цена должна быть > 0'),
taxRate: z.number().min(0).max(100, 'НДС от 0 до 100%'),
});
const InvoiceTableSchema = z.object({
invoiceNumber: z.string().min(1, 'Номер инвойса обязателен'),
customerName: z.string().min(2, 'Укажите клиента'),
items: z.array(InvoiceItemSchema).min(1, 'Добавьте хотя бы одну позицию'),
});
export type InvoiceFormData = z.infer<typeof InvoiceTableSchema>;
export type InvoiceItemData = z.infer<typeof InvoiceItemSchema>;
// 2. Изолированный компонент вычисляемой ячейки (Subtotal)
interface CalculatedSubtotalCellProps {
control: Control<InvoiceFormData>;
index: number;
}
const CalculatedSubtotalCell = React.memo(function CalculatedSubtotalCell({
control,
index,
}: CalculatedSubtotalCellProps) {
// Точечная подписка на quantity, unitPrice и taxRate конкретной строки
const [quantity, unitPrice, taxRate] = useWatch({
control,
name: [
`items.${index}.quantity`,
`items.${index}.unitPrice`,
`items.${index}.taxRate`,
] as const,
});
const validQty = Number(quantity) || 0;
const validPrice = Number(unitPrice) || 0;
const validTax = Number(taxRate) || 0;
const rawTotal = validQty * validPrice;
const withTax = rawTotal + (rawTotal * validTax) / 100;
return (
<td className="px-4 py-2 font-mono text-sm text-cyan-400 font-semibold text-right">
${withTax.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</td>
);
});
// 3. Мемоизированный компонент строки таблицы
interface TableRowProps {
fieldId: string;
index: number;
register: UseFormRegister<InvoiceFormData>;
control: Control<InvoiceFormData>;
onRemove: (index: number) => void;
errors?: any;
}
const InvoiceTableRow = React.memo(function InvoiceTableRow({
fieldId,
index,
register,
control,
onRemove,
errors,
}: TableRowProps) {
return (
<tr key={fieldId} className="border-b border-slate-800 hover:bg-slate-800/40 transition-colors">
{/* Номер строки */}
<td className="px-3 py-2 text-xs font-mono text-slate-500 text-center w-12">
{index + 1}
</td>
{/* SKU */}
<td className="px-3 py-2">
<input
type="text"
placeholder="SKU-101"
className="w-24 px-2 py-1 bg-slate-950 border border-slate-700 rounded text-xs text-white focus:outline-none focus:border-cyan-500"
{...register(`items.${index}.sku`)}
/>
{errors?.sku && (
<span className="block text-[10px] text-red-400">{errors.sku.message}</span>
)}
</td>
{/* Описание */}
<td className="px-3 py-2">
<input
type="text"
placeholder="Наименование товара"
className="w-full px-2 py-1 bg-slate-950 border border-slate-700 rounded text-xs text-white focus:outline-none focus:border-cyan-500"
{...register(`items.${index}.description`)}
/>
{errors?.description && (
<span className="block text-[10px] text-red-400">{errors.description.message}</span>
)}
</td>
{/* Количество */}
<td className="px-3 py-2 w-24">
<input
type="number"
min="1"
className="w-full px-2 py-1 bg-slate-950 border border-slate-700 rounded text-xs text-white text-right focus:outline-none focus:border-cyan-500"
{...register(`items.${index}.quantity`, { valueAsNumber: true })}
/>
</td>
{/* Цена за единицу */}
<td className="px-3 py-2 w-28">
<input
type="number"
step="0.01"
className="w-full px-2 py-1 bg-slate-950 border border-slate-700 rounded text-xs text-white text-right focus:outline-none focus:border-cyan-500"
{...register(`items.${index}.unitPrice`, { valueAsNumber: true })}
/>
</td>
{/* Ставка налога */}
<td className="px-3 py-2 w-20">
<input
type="number"
min="0"
max="100"
className="w-full px-2 py-1 bg-slate-950 border border-slate-700 rounded text-xs text-white text-right focus:outline-none focus:border-cyan-500"
{...register(`items.${index}.taxRate`, { valueAsNumber: true })}
/>
</td>
{/* Вычисляемый итог (Изолированный рендер) */}
<CalculatedSubtotalCell control={control} index={index} />
{/* Кнопка удаления */}
<td className="px-3 py-2 text-center w-12">
<button
type="button"
onClick={() => onRemove(index)}
className="px-2 py-1 text-xs text-red-400 hover:text-red-300 hover:bg-red-950/40 rounded transition-colors"
title="Удалить позицию"
>
✕
</button>
</td>
</tr>
);
});
// 4. Главный контейнер таблицы
export function HighLoadInvoiceMatrix() {
const {
register,
control,
handleSubmit,
formState: { errors },
} = useForm<InvoiceFormData>({
resolver: zodResolver(InvoiceTableSchema),
defaultValues: {
invoiceNumber: 'INV-2026-001',
customerName: 'Enterprise Cloud Systems',
items: [
{ sku: 'SRV-01', description: 'Выделенный сервер AMD EPYC', quantity: 2, unitPrice: 450, taxRate: 20 },
{ sku: 'NET-04', description: 'Транзит 10 Gbps BGP', quantity: 1, unitPrice: 1200, taxRate: 20 },
],
},
});
// Инициализация хука динамических полей
const { fields, append, remove, move } = useFieldArray({
control,
name: 'items',
});
const onSubmit = (data: InvoiceFormData) => {
console.log('Отправка формы со спецификацией:', data);
};
return (
<div className="p-6 bg-slate-900 border border-slate-800 rounded-xl shadow-2xl">
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-xl font-bold text-white">Редактор спецификации счета</h2>
<p className="text-xs text-slate-400">Активных строк в памяти: {fields.length}</p>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() =>
append({
sku: `SKU-${Math.floor(Math.random() * 900) + 100}`,
description: 'Новая товарная позиция',
quantity: 1,
unitPrice: 100,
taxRate: 20,
})
}
className="px-3 py-1.5 bg-cyan-600 hover:bg-cyan-500 text-white rounded text-xs font-semibold transition-colors"
>
+ Добавить строку
</button>
</div>
</div>
<form onSubmit={handleSubmit(onSubmit)}>
<div className="overflow-x-auto border border-slate-800 rounded-lg">
<table className="w-full text-left border-collapse">
<thead>
<tr className="bg-slate-950 border-b border-slate-800 text-[11px] font-bold uppercase tracking-wider text-slate-400">
<th className="px-3 py-2 text-center">#</th>
<th className="px-3 py-2">SKU</th>
<th className="px-3 py-2">Описание</th>
<th className="px-3 py-2 text-right">Кол-во</th>
<th className="px-3 py-2 text-right">Цена ($)</th>
<th className="px-3 py-2 text-right">НДС (%)</th>
<th className="px-4 py-2 text-right">Сумма с НДС</th>
<th className="px-3 py-2 text-center">Действие</th>
</tr>
</thead>
<tbody>
{fields.map((field, index) => (
<InvoiceTableRow
key={field.id} // КРИТИЧЕСКИ ВАЖНО: строго field.id
fieldId={field.id}
index={index}
register={register}
control={control}
onRemove={remove}
errors={errors.items?.[index]}
/>
))}
</tbody>
</table>
</div>
<div className="mt-4 flex justify-end">
<button
type="submit"
className="px-6 py-2 bg-emerald-600 hover:bg-emerald-500 text-white font-semibold text-sm rounded-lg transition-colors"
>
Сохранить и провести инвойс
</button>
</div>
</form>
</div>
);
}Для структур вида «Заказ -> Список позиций -> Список скидок к позиции» используется паттерн вложенных контроллеров:
function SubItemDiscountList({
nestIndex,
control,
register
}: {
nestIndex: number;
control: Control<any>;
register: any;
}) {
// Второй уровень вложенности: путь items.${nestIndex}.discounts
const { fields, append, remove } = useFieldArray({
control,
name: `items.${nestIndex}.discounts`,
});
return (
<div className="pl-6 space-y-2">
{fields.map((field, k) => (
<div key={field.id} className="flex gap-2 items-center">
<input {...register(`items.${nestIndex}.discounts.${k}.code`)} placeholder="Промокод" />
<input {...register(`items.${nestIndex}.discounts.${k}.percent`)} type="number" />
<button type="button" onClick={() => remove(k)}>✕</button>
</div>
))}
<button type="button" onClick={() => append({ code: '', percent: 0 })}>
+ Скидка
</button>
</div>
);
}При перетаскивании строк пользователи ожидают, что введенный текст, фокус инпута и сообщения об ошибках валидации не сбросятся. Стандартная ошибка — использование setValue или ручная мутация исходного стейта, приводящая к полной перерисовке.
Вместо этого используется метод move(fromIndex, toIndex) из useFieldArray:
import { DndContext, type DragEndEvent, closestCenter } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
function SortableTableWrapper({ fields, move, children }: any) {
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
const oldIndex = fields.findIndex((item: any) => item.id === active.id);
const newIndex = fields.findIndex((item: any) => item.id === over.id);
// Метод move атомарно переставляет ссылки в fieldsRef без пересоздания DOM-узлов
move(oldIndex, newIndex);
}
};
return (
<DndContext collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={fields.map((f: any) => f.id)} strategy={verticalListSortingStrategy}>
{children}
</SortableContext>
</DndContext>
);
}useFieldArray инициализирует наблюдатель над массивом полей и генерирует уникальный список field.id для каждой записи.
field.id создается один раз при монтировании и остается неизменным до удаления записи.
Инпуты связываются по строгому синтаксическому пути items.${index}.propName. RHF регистрирует HTMLElement в карте fieldsRef.
DOM-узел удерживается напрямую, исключая необходимость в реактивном стейте React.
При вызове remove(index) или move(from, to) RHF выполняет сдвиг ключей в карте указателей без пересоздания нетронутых элементов.
React удаляет только отвязанный узел по его field.id, не затрагивая остальные строки.
Ячейки с useWatch слушают мутации только по своему индексу. При изменении цены в строке #42 срабатывает только ячейка #42.
Изолированные слушатели предотвращают каскадные рендеры родительской таблицы.
В таблице на 300 строк требуется вычислить общую сумму всех позиций (Grand Total) и отобразить ее в подвале таблицы. Какой способ реализации не вызовет зависаний при вводе символов в ячейках количества?
Самая распространенная ошибка в useFieldArray:
// ❌ ГРУБАЯ ОШИБКА: key={index}
{fields.map((field, index) => (
<div key={index}>
<input {...register(`items.${index}.name`)} />
</div>
))}
// ✅ ПРАВИЛЬНО: Строго field.id
{fields.map((field, index) => (
<div key={field.id}>
<input {...register(`items.${index}.name`)} />
</div>
))}Использование key={index} приводит к удалению последнего DOM-узла при удалении промежуточной строки, утечкам фокуса ввода и отображению неверных значений в полях.
Чтение всего массива строк на верхнем уровне таблицы:
// ❌ АНТИПАТТЕРН: Вызывает перерисовку всей таблицы при каждом нажатии клавиши
export function BigTable() {
const { control } = useForm();
const items = useWatch({ control, name: 'items' }); // Подписка на ВСЕ поля массива!
// ...
}При таком коде любая буква, набранная в любой ячейке, вызывает повторный рендер всей таблицы и всех смонтированных строк. Вычисляемые агрегаты (суммы, счетчики) должны выноситься в отдельные дочерние компоненты.
Попытка обновить массив полей построчно:
// ❌ ОЧЕНЬ МЕДЛЕННО: 500 последовательных обновлений
for (let i = 0; i < 500; i++) {
setValue(`items.${i}.price`, newPrices[i]);
}
// ✅ МАКСИМАЛЬНО БЫСТРО: Атомарная замена через replace()
replace(updatedItemsArray);Метод replace(newArray) из useFieldArray выполняет пакетное обновление внутренней карты ссылок за один проход, сокращая время обновления с 600 мс до менее 5 мс.
Если разработчик пытается отрендерить не все строки, а отфильтровать их:
// ❌ ОШИБКА: Разрыв индексов в именах полей
{fields
.filter(f => f.active)
.map((field, index) => (
<input key={field.id} {...register(`items.${index}.name`)} />
))}Индекс в register('items.' + index + '.name') обязан соответствовать реальному индексу элемента в исходном массиве fields. Если отфильтровать элементы, index в .map() станет 0, 1, 2, тогда как исходные элементы имели индексы 3, 8, 15. Это приведет к перезаписи чужих полей и повреждению структуры данных.