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

Структура проекта

Одна форма — один модуль-папка. Раскладка по умолчанию — плоская: каждый concern живёт в отдельном файле в корне модуля, а вся форма (сборка + все шаги инлайн) — в одном index.tsx. Папки (lib/ / schema/ / components/) заводят только когда форма разрастётся.

Плоская раскладка

forms/
└── credit-application/ # Модуль формы — один файл на concern
├── index.tsx # сборка формы + разметка (все шаги инлайн)
├── types.ts # тип формы + enum'ы + тип опции { value, label }
├── model.ts # createModel + начальные значения + фабрики элементов массива
├── form.schema.ts # схема: { value: model.$.x, component, componentProps }
├── form.behavior.ts # defineFormBehavior: compute / enableWhen / onChange
├── validation.ts # ВСЯ валидация формы
├── data-sources.ts # опции + асинхронные загрузчики
└── api.ts # submit + prefill
ФайлЗа что отвечает
types.tsтип формы (type-алиас), enum'ы полей, тип { value, label } для опций
model.tscreateModel<T>(initial) — источник истины для значений
form.schema.tsпривязка полей к сигналам модели, component и componentProps
form.behavior.tsреактивные правила: вычисляемые поля, условная доступность, реакции
validation.tsвсе валидаторы + запуск validateFormModel
data-sources.tsсловари опций и загрузчики (держим их вне схемы)
api.tsотправка и предзаполнение
index.tsxсборка createXxxForm() + разметка
Почему dot-префикс у schema/behavior

Только у schema и behavior есть два слоя — модельный (form.schema.ts, form.behavior.ts) и рендер-слой у renderer-пакетов (renderer.schema.*, renderer.behavior.ts). Остальные concern'ы общие на всю форму, поэтому их файлы плоские: types.ts, model.ts, validation.ts, …

Сборка формы

Файлы соединяются в одну фабрику createXxxForm(): модель → схема → behavior → createForm.

forms/credit-application/model.ts
import { createModel, type FormModel } from '@reformer/core';
import type { CreditApplicationForm } from './types';

export const createCreditApplicationModel = (): FormModel<CreditApplicationForm> =>
createModel<CreditApplicationForm>({
loanType: 'consumer',
loanAmount: null,
propertyValue: null,
monthlyPayment: null,
// …начальные значения всех полей
});
forms/credit-application/form.schema.ts
import type { FormModel } from '@reformer/core';
import { Input, Select } from '@reformer/ui-kit';
import { required, min } from '@reformer/core/validators';
import { LOAN_TYPES } from './data-sources';
import type { CreditApplicationForm } from './types';

export const creditApplicationSchema = (model: FormModel<CreditApplicationForm>) => ({
loanType: {
value: model.$.loanType,
component: Select,
componentProps: { label: 'Тип кредита', options: LOAN_TYPES },
validators: [required()],
},
loanAmount: {
value: model.$.loanAmount,
component: Input,
componentProps: { label: 'Сумма', type: 'number' },
validators: [required(), min(50000)],
},
monthlyPayment: { value: model.$.monthlyPayment, component: Input, disabled: true },
});
forms/credit-application/form.behavior.ts
import { defineFormBehavior, compute, enableWhen } from '@reformer/core/behaviors';
import type { CreditApplicationForm } from './types';

export const creditApplicationBehavior = defineFormBehavior<CreditApplicationForm>(({ model }) => {
enableWhen([model.$.propertyValue], () => model.loanType === 'mortgage', {
resetOnDisable: true,
});
// упрощённый расчёт; реальную формулу вынесите в helper из lib
compute(model.$.monthlyPayment, () => (model.loanAmount ?? 0) / 12);
});
forms/credit-application/index.tsx
import { useMemo } from 'react';
import { createForm } from '@reformer/core';
import { createCreditApplicationModel } from './model';
import { creditApplicationSchema } from './form.schema';
import { creditApplicationBehavior } from './form.behavior';
import type { CreditApplicationForm } from './types';

// Одна фабрика собирает форму: model → schema → behavior → createForm
export const createCreditApplicationForm = () => {
const model = createCreditApplicationModel();
return {
model,
form: createForm<CreditApplicationForm>({
model,
schema: creditApplicationSchema(model),
behavior: creditApplicationBehavior,
}),
};
};

Тип формы — type, а не interface

Всё, что попадает в FormProxy<T> (корневую форму, вложенные группы, тип элемента массива), объявляйте через type-алиас. Интерпретатору схемы нужна индекс-сигнатура (Record<string, …>): у type она есть структурно, у interface — нет.

forms/credit-application/types.ts
export type LoanType = 'consumer' | 'mortgage' | 'car';

export type PropertyItem = {
type: 'apartment' | 'house' | 'car';
estimatedValue: number;
};

// ✅ type-алиас — структурная индекс-сигнатура
export type CreditApplicationForm = {
loanType: LoanType;
loanAmount: number | null;
propertyValue: number | null;
monthlyPayment: number | null;
properties: PropertyItem[];
};
// ❌ interface — не подойдёт как форма/элемент массива
interface CreditApplicationForm {
/* … */
}
подсказка
Опциональные числа — number | null

Числа, которые пользователь может очистить, объявляйте как number | null (конвенция «поле пустое»). Встроенные валидаторы (min, max, minLength, …) пропускают пустые значения.

Стабильность инстанса — useMemo

В React модель и форму создают один раз через useMemo(() => …, []). Без этого на каждый рендер пересоздавались бы модель, схема и ноды — форма теряла бы состояние.

import { useMemo } from 'react';
import { createCreditApplicationForm } from './index';

export function CreditApplicationView() {
// Пустой массив зависимостей → форма собирается ровно один раз
const { form, model } = useMemo(() => createCreditApplicationForm(), []);

// …разметка со всеми шагами инлайн
}
Не собирайте форму в теле компонента

createModel / createForm прямо в рендере (без useMemo) — источник «мигающих» форм и потерянного ввода. Всегда оборачивайте сборку в useMemo(..., []).

Передача формы в дочерние компоненты — через props

Форму (и отдельные ноды) передавайте вниз явными props типа FormProxy<T> / FieldNode<T>, а не через React Context. Явная передача делает компоненты предсказуемыми и тестируемыми: каждый видит ровно ту ноду, с которой работает.

import type { FormProxy } from '@reformer/core';

function LoanStep({ form }: { form: FormProxy<CreditApplicationForm> }) {
return (
<section>
<FormField control={form.loanType} />
<FormField control={form.loanAmount} />
</section>
);
}

export function CreditApplicationView() {
const { form } = useMemo(() => createCreditApplicationForm(), []);
return <LoanStep form={form} />;
}

Дочерний компонент, который мутирует массив, дополнительно принимает model (мутации идут по модели: model.items.push / removeAt) — тоже явным пропом FormModel<T>.

Почему не context

Форма уже реактивна на уровне нод: подписки идут через хуки (useFormControl и т.п.), а не через контекст. Проброс form пропсом ничего не «перерисовывает лишнего» и оставляет граф зависимостей явным. Context пришлось бы заводить лишь для очень глубоких деревьев — по умолчанию он не нужен.

Масштабирование

СложностьРаскладка
Простая формаодин файл index.tsx (модель + схема + behavior + компонент)
Плоский модульпо умолчанию. Один файл на concern + index.tsx со всеми шагами инлайн
Папки (lib/ + schema/ + components/)большие формы: concern'ы по папкам, по компоненту на шаг, переиспользуемые под-формы

Когда плоский модуль становится неудобным (много шагов, отдельные владельцы, переиспользуемые под-формы) — вынесите в три папки: lib/ (доменные помощники), schema/ (описание формы), components/ (React-раскладка), оставив в корне только entry-компонент и index.ts. model / form.behavior / validation / data-sources при этом переиспользуются как есть — никогда не дублируйте их между таргетами.

Правила

ПравилоЗачем
Начинать с плоской раскладкиодин файл на concern; без преждевременных папок
Тип формы через typeнужна структурная индекс-сигнатура для FormProxy<T>
Собирать форму в useMemo(…, [])стабильный инстанс, форма не теряет состояние
Пробрасывать форму пропсамипредсказуемость и тестируемость вместо неявного контекста
Вся валидация в validation.tsодно место, куда смотреть
Data sources — отдельный файлсхема читаемее, renderer-json ссылается по имени

Дальше

  • Композиция схем — переиспользуемые схемы и валидаторы.
  • React-хуки — чтение состояния нод в компонентах.
  • Behaviors — вычисляемые поля и условная логика.