Вложенные формы в React: массивы, drag & drop и когда использовать таблицы
Form.Group для вложенных объектов, Form.Group.List для динамических массивов с drag & drop. Когда переключаться на TableEditor вместо Group.List.
14 мая 2026 г.
--TL;DR:
Кому полезно:
- Junior: понять, как работать с вложенными данными в формах без ручного маппинга индексовMiddle: освоить паттерн вложенных массивов (массив в массиве) и выбор между Group.List и TableEditorSenior: оценить архитектуру автоматических путей и интеграцию с @dnd-kit для drag & drop
Шестая статья из цикла «@letar/forms — от боли к декларативным формам». Как работать с вложенными объектами, динамическими массивами и drag & drop сортировкой в формах.
Проблема: плоские формы — это фантазия
В учебниках формы плоские:
interface Order {
customer: {
name: string
phone: string
address: {
city: string
street: string
building: string
}
}
items: Array<{
product: string
quantity: number
price: number
}>
comment: string
}
Три уровня вложенности + массив. Попробуйте это сделать на React Hook Form или TanStack Form «из коробки» — и вы быстро утонете в
Form.Group — вложенные объекты
<Form schema={OrderSchema} initialValue={data} onSubmit={save}>
{/* Вложенный объект customer */}
<Form.Group name="customer">
<Form.Field.String name="name" /> {/* → customer.name */}
<Form.Field.Phone name="phone" /> {/* → customer.phone */}
{/* Ещё глубже: customer.address */}
<Form.Group name="address">
<Form.Field.City name="city" /> {/* → customer.address.city */}
<Form.Field.String name="street" /> {/* → customer.address.street */}
<Form.Field.String name="building" />
{/* → customer.address.building */}
</Form.Group>
</Form.Group>
<Form.Field.Textarea name="comment" /> {/* → comment (корневой уровень) */}
<Form.Button.Submit />
</Form>
Визуальная группировка
<Form.Group name="address" card title="Адрес доставки">
<Form.Field.City name="city" />
<Form.Field.String name="street" />
</Form.Group>
Form.Group.List — динамические массивы

<Form schema={OrderSchema} initialValue={data} onSubmit={save}>
<Form.Group name="customer">
<Form.Field.String name="name" />
<Form.Field.Phone name="phone" />
</Form.Group>
{/* Динамический массив items */}
<Form.Group.List name="items">
{/* Каждый элемент массива */}
<HStack>
<Form.Field.Combobox name="product" />
<Form.Field.Number name="quantity" />
<Form.Field.Currency name="price" />
<Form.Group.List.Button.Remove />
</HStack>
{/* Кнопка добавления */}
<Form.Group.List.Button.Add>+ Добавить товар</Form.Group.List.Button.Add>
</Form.Group.List>
<Form.Button.Submit />
</Form>
- Рендерит children для каждого элемента массиваАвтоматически управляет индексами (
Кастомизация шаблона элемента
<Form.Group.List
name="phones"
min={1} // Минимум 1 элемент
max={5} // Максимум 5
addLabel="Добавить телефон" // Текст кнопки
defaultItem={{ number: '', type: 'mobile' }} // Шаблон нового элемента
>
<HStack>
<Form.Field.Phone name="number" />
<Form.Field.NativeSelect name="type" options={phoneTypes} />
<Form.Group.List.Button.Remove />
</HStack>
</Form.Group.List>
Drag & Drop сортировка
Для переупорядочивания элементов массива подключается
<Form.Group.List name="sections" sortable>
<HStack>
<Form.Group.List.DragHandle /> {/* ≡ иконка для перетаскивания */}
<Form.Field.String name="title" />
<Form.Group.List.Button.Remove />
</HStack>
</Form.Group.List>
Вертикальный и горизонтальный drag & drop
{
/* Вертикальный список (по умолчанию) */
}
;<Form.Group.List name="steps" sortable direction="vertical">
...
</Form.Group.List>
{
/* Горизонтальный (например, карточки) */
}
;<Form.Group.List name="slides" sortable direction="horizontal">
...
</Form.Group.List>
Вложенные массивы
Массив внутри массива — вполне реальный кейс (форма расписания, конструктор курса):
const CourseSchema = z.object({
title: z.string().meta({ ui: { title: 'Название курса' } }),
modules: z.array(
z.object({
title: z.string().meta({ ui: { title: 'Модуль' } }),
lessons: z.array(
z.object({
title: z.string().meta({ ui: { title: 'Урок' } }),
duration: z.number().meta({ ui: { title: 'Длительность (мин)', fieldType: 'duration' } }),
})
),
})
),
})
<Form schema={CourseSchema} initialValue={data} onSubmit={save}>
<Form.Field.String name="title" />
<Form.Group.List name="modules" sortable>
<Box p={4} borderWidth={1} borderRadius="md">
<HStack>
<Form.Group.List.DragHandle />
<Form.Field.String name="title" />
<Form.Group.List.Button.Remove />
</HStack>
{/* Вложенный массив уроков */}
<Form.Group.List name="lessons">
<HStack>
<Form.Field.String name="title" />
<Form.Field.Duration name="duration" />
<Form.Group.List.Button.Remove />
</HStack>
<Form.Group.List.Button.Add>+ Урок</Form.Group.List.Button.Add>
</Form.Group.List>
</Box>
<Form.Group.List.Button.Add>+ Модуль</Form.Group.List.Button.Add>
</Form.Group.List>
<Form.Button.Submit />
</Form>
Пути генерируются автоматически:
Валидация массивов в Zod
const OrderSchema = z.object({
items: z
.array(
z.object({
product: z.string().min(1, 'Выберите товар'),
quantity: z.number().min(1, 'Минимум 1'),
price: z.number().min(0),
})
)
.min(1, 'Добавьте хотя бы один товар') // Валидация длины массива
.max(20, 'Максимум 20 позиций'),
})
Ошибки показываются:
- На уровне элемента: «Выберите товар» — у конкретного поляНа уровне массива: «Добавьте хотя бы один товар» — над списком
Пример из продакшена: форма заказа
function OrderForm({ products }) {
return (
<Form schema={OrderSchema} initialValue={emptyOrder} onSubmit={submitOrder}>
<VStack gap={6}>
<Form.Group name="customer" card title="Покупатель">
<HStack>
<Form.Field.String name="name" />
<Form.Field.Phone name="phone" />
</HStack>
<Form.Field.String name="email" />
</Form.Group>
<Form.Group name="delivery" card title="Доставка">
<Form.Field.City name="city" />
<Form.Field.Address name="address" />
<Form.Field.Date name="preferredDate" />
</Form.Group>
<Box>
<Heading size="sm" mb={2}>
Товары
</Heading>
<Form.Group.List name="items" sortable min={1} max={20}>
<HStack>
<Form.Group.List.DragHandle />
<Form.Field.Combobox name="productId" options={products} />
<Form.Field.NumberInput name="quantity" min={1} />
<Form.Group.List.Button.Remove />
</HStack>
<Form.Group.List.Button.Add>+ Добавить товар</Form.Group.List.Button.Add>
</Form.Group.List>
</Box>
<Form.Field.Textarea name="comment" />
<Form.Errors title="Исправьте ошибки:" />
<Form.Button.Submit>Оформить заказ</Form.Button.Submit>
</VStack>
</Form>
)
}
Итоги
| Компонент | Что делает |
|---|---|
| Вложенный объект (префикс пути) |
| Динамический массив (add/remove/sort) |
| Добавление элемента |
| Удаление элемента |
| Ручка для drag & drop |
Ключевые принципы:
- Автоматические пути —
Альтернатива: TableEditor для табличных данных
Если массив — это таблица (товары в заказе, строки сметы),
<Form.Field.TableEditor
name="items"
columns={[
{ name: 'product', width: '40%' },
{ name: 'qty', width: '15%', align: 'right' },
{ name: 'price', width: '15%', align: 'right' },
{ name: 'total', computed: (row) => row.qty * row.price, label: 'Итого' },
]}
addLabel="Добавить товар"
sortable
footer={[{ column: 'total', aggregate: 'sum', label: 'Итого:' }]}
/>
Что даёт
| Возможность | Group.List | TableEditor |
|---|---|---|
| Inline-редактирование | Вручную | Из коробки |
| Computed-колонки | Вручную | Из коробки |
| Footer-агрегации | Нет | SUM, AVG, COUNT, MIN, MAX |
| DnD сортировка | | |
| Copy-paste из Excel | Нет | Из коробки |
| Мобильный вид | Вручную | Авто (карточки) |
Правило: если данные — линейная таблица (строки × колонки) →
Для продвинутых сценариев (сортировка, фильтрация, виртуализация 1000+ строк) есть
Попробовать
- Группы и массивы: forms-example.letar.best/examples/groupsИсходный код: GitHubКлонировать:
В следующей статье —
Это шестая статья из цикла «@letar/forms — от боли к декларативным формам». Предыдущая: Мультистеп и условный рендеринг | Следующая: FromSchema.