Перейти к содержимому

Назад к блогу

reactформыtypescriptархитектура

От первой формы до архитектуры: Compound Components, Context и createForm()

Compound Components дают полный контроль над вёрсткой при типобезопасности TypeScript. Разбираем Object.assign, Context API и createForm() с lazy-loading.

11 мая 2026 г.

--TL;DR:

    Compound Components (
    ) дают полный контроль над вёрсткой при типобезопасности на уровне TypeScript.JSON-конфиги (react-jsonschema-form) компактнее, но кастомизация — боль.
    позволяет расширить библиотеку app-specific полями через lazy-loading.4 уровня контроля: FromSchema → AutoFields → Form.Field.* → useAppForm.

Кому полезно:

    Junior: поймёте разницу между JSON-конфигом и Compound ComponentsMiddle: увидите, как устроены Context API и namespace-паттерн через Object.assignSenior: оцените архитектуру createForm() с lazy-loading и tree-shaking

<details><summary>Что такое Compound Components (если не знакомы)</summary>

Compound Components — паттерн React, при котором группа компонентов работает вместе через общий Context. Родитель хранит состояние, дети его используют. Классический пример —

+
или
+
+
.

В @letar/forms:

(хранит состояние) →
(поле ввода, получает контекст) →
(кнопка, знает про isSubmitting).

</details>


Третья статья из цикла «@letar/forms — от боли к декларативным формам». Почему мы выбрали

вместо JSON-конфигов, как устроен
и что даёт паттерн Compound Components.


Два пути: конфиг или компоненты

Когда вы строите библиотеку форм, первый архитектурный вопрос — как пользователь будет описывать форму?

Путь 1: JSON-конфиг (react-jsonschema-form)

const schema = {
  type: 'object',
  properties: {
    name: { type: 'string', title: 'Имя' },
    email: { type: 'string', format: 'email', title: 'Email' },
    role: { type: 'string', enum: ['admin', 'user'], title: 'Роль' },
  },
}

const uiSchema = {
  email: { 'ui:placeholder': 'user@example.com' },
  role: { 'ui:widget': 'radio' },
}

<JsonSchemaForm schema={schema} uiSchema={uiSchema} onSubmit={save} />

Плюсы: Компактно, сериализуемо, можно генерировать на сервере.Минусы:

    Кастомная вёрстка — боль. Хотите
    вокруг двух полей? Нужен кастомный templateНет автокомплита в IDE.
    — опечатка, которую вы найдёте в рантаймеДва объекта (schema + uiSchema) вместо одного источникаРасширяемость через строковые ключи (
    )

Путь 2: Compound Components (@letar/forms)

<Form schema={Schema} initialValue={data} onSubmit={save}>
  <HStack>
    <Form.Field.String name="name" />
    <Form.Field.String name="email" />
  </HStack>
  <Form.Field.RadioGroup name="role" />
  <Form.Button.Submit>Сохранить</Form.Button.Submit>
</Form>

Плюсы:

    Полный контроль над вёрсткой — это обычный JSXTypeScript автокомплит на всём:
    , пропсы компонента, тип значенияОдин импорт —
    содержит все вложенные компонентыКастомизация — стандартными React-средствами (пропсы, обёртки, children)

Минусы: Не сериализуемо (но для этого есть

).

Мы выбрали второй путь. Вот почему.


Почему Compound Components

1. Вёрстка — это JSX

Форма — это не только набор полей. Это layout:

<Form schema={Schema} initialValue={data} onSubmit={save}>
  <VStack gap={6}>
    {/* Две колонки */}
    <HStack gap={4}>
      <Form.Field.String name="firstName" />
      <Form.Field.String name="lastName" />
    </HStack>

    {/* Полная ширина */}
    <Form.Field.String name="email" />

    {/* Разделитель */}
    <Separator />
    <Heading size="sm">Дополнительно</Heading>

    {/* Условное отображение */}
    <Form.When field="role" is="company">
      <Form.Field.String name="companyName" />
      <Form.Field.String name="inn" />
    </Form.When>

    <Form.Button.Submit>Сохранить</Form.Button.Submit>
  </VStack>
</Form>

С JSON-конфигом это потребовало бы отдельного DSL для описания layout. У нас вместо DSL — React.

2. TypeScript из коробки

<Form schema={Schema} initialValue={data} onSubmit={save}>
  <Form.Field.String name="titel" />
  // ^^^^^ // TS Error: Type '"titel"' is not assignable to type '"title" | "email" | ...'
</Form>

Опечатка в имени поля — ошибка компиляции. Не рантайм, не тест — редактор подчеркнёт красным. С JSON-конфигом вы бы узнали об этом только при рендере.

3. Один импорт

import { Form } from '@letar/forms'

// Всё доступно через точку:
Form.Field.String
Form.Field.Number
Form.Field.Select
Form.Field.Date
Form.Field.Phone
Form.Field.Currency
Form.Field.RichText
// ... ещё 42 поля

Form.Group
Form.Group.List
Form.Group.List.Button.Add
Form.Group.List.Button.Remove

Form.Steps
Form.Steps.Step
Form.Steps.Navigation

Form.When
Form.Errors
Form.DebugValues
Form.OfflineIndicator

Form.Button.Submit
Form.Button.Reset

Не нужно импортировать 20 компонентов. Один

— и точка-нотация делает всё discoverable.


Как устроено внутри

Object.assign — ключ к вложенным компонентам

// Упрощённая версия
import { FieldNumber } from './fields/field-number'
import { FieldString } from './fields/field-string'
import { FormRoot } from './form-root'
// ... ещё 48 полей
import { ButtonReset, ButtonSubmit } from './form-buttons'
import { FormGroup, FormGroupList } from './form-group'
import { FormSteps } from './form-steps'

const FormField = {
  String: FieldString,
  Number: FieldNumber,
  Select: FieldSelect,
  Date: FieldDate,
  Phone: FieldPhone,
  CreditCard: FieldCreditCard,
  TableEditor: FieldTableEditor,
  DataGrid: FieldDataGrid,
  Hidden: FieldHidden,
  Calculated: FieldCalculated,
  // ... все 50+ полей
}

const FormButton = {
  Submit: ButtonSubmit,
  Reset: ButtonReset,
}

export const Form = Object.assign(FormRoot, {
  Field: FormField,
  Group: Object.assign(FormGroup, {
    List: Object.assign(FormGroupList, {
      Button: {
        Add: FormGroupListButtonAdd,
        Remove: FormGroupListButtonRemove,
      },
      DragHandle: FormGroupListDragHandle,
    }),
  }),
  Steps: Object.assign(FormSteps, {
    Step: FormStepsStep,
    Indicator: FormStepsIndicator,
    Navigation: FormStepsNavigation,
    CompletedContent: FormStepsCompleted,
  }),
  When: FormWhen,
  Watch: FormWatch, // Отслеживание изменений полей
  Errors: FormErrors,
  Divider: FormDivider, // Разделитель секций
  InfoBlock: FormInfoBlock, // Информационный блок
  DirtyGuard: FormDirtyGuard, // Предупреждение о несохранённых данных
  Button: FormButton,
  FromSchema: FormFromSchema,
  FromTemplate: FormFromTemplate, // 10 готовых шаблонов
  AutoFields: FormAutoFields,
  Builder: FormBuilder, // JSON form builder
  Captcha: FormCaptcha, // CAPTCHA (Turnstile/reCAPTCHA/hCaptcha)
  Document: FormDocument, // Российские документы (INN, KPP, OGRN, ...)
  DebugValues: FormDebugValues,
  OfflineIndicator: FormOfflineIndicator,
  SyncStatus: FormSyncStatus,
})

превращает функцию-компонент в объект с вложенными свойствами. TypeScript видит и компонент, и его «дочерние» компоненты через точку.

Context API: как поля знают о форме

Когда вы пишете

, компонент String не получает форму через пропсы. Он берёт её из контекста:

<Form>                    ← FormContext.Provider (schema, form instance)
  <Form.Group name="user">  ← GroupContext.Provider (prefix: "user")
    <Form.Field.String name="email" />  ← читает FormContext + GroupContext
    // Реальный путь: "user.email"
  </Form.Group>
</Form>

Три уровня контекста:

Дерево контекстов: Form → Group → Field

    FormContext — инстанс TanStack Form, Zod-схема, конфигGroupContext — префикс пути (для вложенных объектов и массивов)FieldContext — конкретное поле, его состояние, ошибки

Это позволяет компонентам быть «умными» без явной передачи данных.

Controlled State: live preview и внешний контроль

Контекст позволяет не только полям читать форму, но и внешним компонентам подписываться на её состояние:

import { useTypedFormSubscribe } from '@letar/forms'

function ProductPreview() {
  const { value } = useTypedFormSubscribe(['name', 'price', 'description'])

  return (
    <Card>
      <Heading>{value.name || 'Без названия'}</Heading>
      <Text>{value.price ? `${value.price} ₽` : ''}</Text>
      <Text>{value.description}</Text>
    </Card>
  )
} // Использование: preview обновляется при каждом изменении полей

<Form schema={ProductSchema} initialValue={data} onSubmit={save}>
  <HStack align="start">
    <VStack flex={1}>
      <Form.Field.String name="name" />
      <Form.Field.Currency name="price" />
      <Form.Field.Textarea name="description" />
    </VStack>
    <ProductPreview /> {/* Живой превью справа */}
  </HStack>
  <Form.Button.Submit />
</Form>

принимает массив имён полей и перерисовывает компонент только при их изменении. Подробнее — в документации Controlled State.


createForm() — фабрика для расширения

Базовый

покрывает 50+ полей. Но что, если вашему приложению нужны кастомные?

import { createDaDataProvider, createForm } from '@letar/forms'
import { BrandCombobox } from './selects/BrandCombobox'
import { CategorySelect } from './selects/CategorySelect'

export const AppForm = createForm({
  // Кастомные поля
  extraFields: {
    DataTable: MyDataTableField,
  },

  // Кастомные select-компоненты
  extraSelects: {
    Category: CategorySelect,
  },

  // Lazy-loaded combobox (грузится только при рендере)
  lazyComboboxes: {
    Brand: () => import('./selects/BrandCombobox').then((m) => m.BrandCombobox),
  },

  // Провайдер адресов по умолчанию
  addressProvider: createDaDataProvider({ token: process.env.DADATA_TOKEN }),
})

Теперь в приложении:

import { AppForm } from '@/lib/form'
<AppForm schema={Schema} initialValue={data} onSubmit={save}>
  <AppForm.Field.String name="title" />
  <AppForm.Select.Category name="categoryId" /> {/* Кастомный */}
  <AppForm.Combobox.Brand name="brandId" /> {/* Lazy-loaded */}
  <AppForm.Field.Address name="address" /> {/* DaData из коробки */}
  <AppForm.Field.DataTable name="items" /> {/* Кастомное поле */}
  <AppForm.Button.Submit />
</AppForm>

— это
на стероидах: мержит ваши компоненты с базовыми, оборачивает lazy-loaded в
+
, и подставляет addressProvider по умолчанию.

Lazy-loading в деталях

function createLazyComponents(lazyMap) {
  const result = {}
  for (const [name, loader] of Object.entries(lazyMap)) {
    result[name] = React.lazy(loader)
  }
  return result
}

Combobox с 10 000 городов не грузится, пока не рендерится. Code splitting бесплатно.


Когда Compound Components — не лучший выбор

Мы не фанатики. Иногда конфиг-подход лучше:

Когда нужна сериализация

Если схемы форм хранятся в БД (form builder, no-code платформы) — JSON-конфиг удобнее. Для этого у нас есть

:

// Генерация из Zod-схемы (по сути, из конфига)
<Form.FromSchema schema={dynamicSchema} initialValue={data} onSubmit={save} exclude={['id', 'createdAt']} />

Это гибрид: схема сериализуемая (Zod), но рендер — через Compound Components.

Когда все формы одинаковые

Если у вас CRUD на 50 моделей и все формы — линейный список полей —

эффективнее ручной вёрстки.

Наш подход: оба варианта

Уровень 1: FromSchema      — одна строка, автогенерация
Уровень 2: AutoFields      — автогенерация + кастомные обёртки
Уровень 3: Form.Field.*    — полный контроль (Compound Components)
Уровень 4: useAppForm      — императивный API для сложных кейсов

Начинаете с FromSchema. Когда нужна кастомизация — спускаетесь на уровень ниже. Не нужно переписывать всё.

Бонус schema-driven архитектуры —

. Поскольку Zod-схема содержит информацию о количестве и типах полей, библиотека может сгенерировать loading-скелетон автоматически:

import { FormSkeleton } from '@letar/forms'

// Пока данные грузятся — скелетон, повторяющий структуру формы
<FormSkeleton fields={OrderSchema} showSubmit />

Без schema-driven архитектуры это 15–20 строк ручных

компонентов, которые нужно поддерживать синхронно с формой.


Сравнение подходов

КритерийJSON-конфиг (RJSF)Compound Components (@letar/forms)
Кастомная вёрсткаСложно (templates)Нативный JSX
TypeScriptСлабыйПолный (автокомплит, проверка имён)
IDE supportМинимальныйПолный (Go to Definition, autocomplete)
СериализацияИз коробкиЧерез FromSchema
Кривая обученияСредняя (DSL)Низкая (это просто React)
РасширяемостьВиджеты по строковым ключамcreateForm() + React компоненты
Tree-shakingСложноObject.assign + lazy()

Итоги

Compound Components — это не «модный паттерн ради паттерна». Это осознанный выбор:

    JSX для вёрстки, Zod для логики — каждый инструмент для своей задачиTypeScript первый — ошибки в именах полей ловятся компиляторомОдин импорт
    содержит всё, discoverable через точкуРасширяемость
    для app-specific полей с lazy-loadingСпектр контроля — от
    (ноль JSX) до
    (полный контроль)

Попробовать

<details><summary>Установка</summary>

bun add @letar/forms
import { Form } from '@letar/forms'
import { z } from 'zod/v4'

const Schema = z.object({
  name: z.string().min(2).meta({ ui: { title: 'Имя' } }),
  email: z.email().meta({ ui: { title: 'Email' } }),
})

<Form schema={Schema} initialValue={{ name: '', email: '' }} onSubmit={save}>
  <Form.Field.String name="name" />
  <Form.Field.String name="email" />
  <Form.Button.Submit>Сохранить</Form.Button.Submit>
</Form>

</details>


Навигация по серии← Предыдущая: Zod .meta() — одна схема для валидации, UI и доступности→ Следующая: 50+ готовых полей для React-форм


Compound Components или JSON-конфиг? Что используете и почему?

Комментарии (0)

Войдите, чтобы оставить комментарий. Войти

Пока нет комментариев. Будьте первым!