Как перестать копировать формы и построить масштабируемую архитектуру create/edit/create-from-source
Термин “Context-driven Reusable Form Pattern” был придуман для названия статьи, у него нет официального происхождения, это скорее инженерный descriptive term, а не канонический паттерн. Можно сказать, что технически название сформировалось эволюционно из нескольких идей, пришедших в frontend из enterprise UI и backend architecture.
Reusable Form — это самая старая часть термина, в CRUD-heavy enterprise UI формы почти всегда переиспользуются, в основе лежит идея:
форма = независимый reusable component
Но одной reusable form оказалось недостаточно. Одна и та же форма должна по-разному работать в сценариях create, edit, import или invite — с разными initial values, validation rules, permissions и submit flow. Это привело к подходу, где форма — универсальный rendering engine, а её поведение определяется runtime context: через mode, strategy или injected business context. Отсюда и название Context-driven:
поведение формы определяется runtime context
Идея этой статьи появилась во время работы над проектом на Vue — я реализовывал сложные формы, которые должны были работать в разных бизнес-сценариях. Но в повседневной работе мой основной стек — React, поэтому все примеры и архитектурные решения будут показаны на React + TypeScript.
Эта статья прежде всего для начинающих React-разработчиков и тех, кто только приходит в enterprise-приложения. Если вы уже замечали, как одна и та же форма постепенно копируется под create, edit, duplicate, import или create-from-template — этот подход поможет перестать плодить копии и сделать архитектуру форм управляемой.
Как множатся формы и почему это не масштабируется
Практически в каждом большом React-приложении рано или поздно появляется одинаковая проблема, сначала у нас есть простая форма создания сущности, потом появляется редактирование, далее возникает сценарий “создать из другой сущности”, потом импорт, следом автозаполнение из внешнего API.
Потом форма начинает открываться из модалки, сайдбара, отдельной страницы, wizard flow и еще пяти разных мест.
И внезапно оказывается, что вместо одной формы у нас уже:
-
CreateClientForm -
EditClientForm -
CreateClientFromLeadForm -
ImportClientForm -
QuickCreateClientForm -
ClientDrawerForm
А внутри — дублирование логики, разъехавшиеся validation rules, бесконечные if (mode === 'edit'), проблемы с синхронизацией состояния и хаос в data flow.
Особенно быстро это происходит в CRM, ERP, admin panel и внутренних enterprise-системах.
Типичные сценарии:
Lead -> ClientOrder -> InvoiceTemplate -> DocumentImportRow -> User
Проблема не в количестве форм, а в том, что каждый новый сценарий требует отдельной копии логики — валидации, начальных значений, сабмита. Изменение одного правила ломает всё или требует правок в десяти местах. Поэтому подход не масштабируется: сложность растёт не линейно, а комбинаторно.
Новички обычно пытаются решить это через:
-
Giant form component;
-
Универсальный
mode prop; -
Огромные conditional rendering блоки;
-
Глобальный store;
-
Дублирование формы.
Но в больших React-приложениях такой подход очень быстро перестает масштабироваться.
В этой статье разберем архитектурный паттерн, который позволяет строить переиспользуемые формы без копипасты и без превращения компонентов в монолит.
Идея паттерна очень простая:
Форма не должна знать, откуда пришли данные и зачем она открыта.
Форма должна работать только с:
-
Текущим state;
-
Schema;
-
Submit handler;
-
Contextual capabilities.
Всё остальное должно жить снаружи.
Хватит писать формы. Пора проектировать фабрику форм.
Почему обычные формы перестают работать
Представим типичную форму клиента.
На старте всё выглядит безобидно.
export const CreateClientForm = () => { return ( <form> <input /> <input /> <button>Create</button> </form> );};
Через некоторое время появляется edit.
export const ClientForm = ({ mode, initialValues }) => { // ...};
Потом появляются десятки условий.
if (mode === 'edit') { // ...}if (mode === 'create-from-lead') { // ...}if (mode === 'import') { // ...}
Дальше — хуже: conditional validation, потом conditional fields, следом conditional submit, async hydration, optimistic updates. В какой-то момент компонент начинает выглядеть как state machine на стероидах.
Но главная проблема не в размере. Главная проблема — смешивание ответственности.
Внутри одной формы внезапно живут:
-
UI и отрисовка;
-
Оркестрация (orchestration);
-
Загрузка данных (data loading);
-
Трансформация и mapping;
-
Permissions и feature toggles;
-
Бизнес-правила;
-
API-интеграция;
-
Роутинг;
-
Аналитика;
Именно этот винегрет делает формы нерасширяемыми.
Главный принцип: разделяем форму на слои
Основная суть паттерна, это разделить форму на несколько независимых слоев.
1. Presentation layer
Компоненты ничего не знают о:
-
Роутинге;
-
Сущностях;
-
Source entity;
-
Create / Edit;
-
REST;
-
GraphQL;
-
Zustand;
-
MobX.
Они только отображают state.
2. Form orchestration layer
Пользовательский hook управляет:
-
Form state;
-
Submit;
-
Hydration;
-
Side effects;
-
Transformations.
3. Context layer
Контекст определяет:
-
Зачем открыта форма;
-
Какие capabilities доступны;
-
Откуда пришли initial values;
-
Какие ограничения действуют.
4. Data source layer
Source adapters преобразуют внешние сущности в form model.
Например:
Lead -> ClientDraftImportRow -> ClientDraftTemplate -> DocumentDraft
Как выглядит архитектура
Возьмем feature clients, структура может выглядеть так.
src/features/clients/├── api/├── components/│ ├── client-form.tsx│ ├── client-fields.tsx│ └── client-submit-button.tsx├── hooks/│ ├── use-client-form.ts│ ├── use-client-form-context.ts│ ├── use-client-submit.ts│ ├── use-client-default-values.ts│ └── use-client-capabilities.ts├── stores/│ └── client-form.store.ts├── types/│ └── client-form.types.ts├── adapters/│ ├── lead-to-client.adapter.ts│ └── import-to-client.adapter.ts├── schemas/│ └── client.schema.ts└── routes/
Обратите внимание:
-
Компоненты здесь максимально тупые.
-
Вся логика вынесена в hooks.
Это особенно важно в React, где композиция становится центральной частью архитектуры, а чистота компонентов — залогом переиспользования.
Начинаем с form model
Формы, которые работают только в среду — это не фича, это диагноз.
Самая распространённая ошибка новичков — взять backend DTO и скормить его форме как state.
Так делать нельзя.
Form model — это модель UI. Она принадлежит интерфейсу.
-
Не backend.
-
Не API.
-
Не схеме базы данных.
Например:
export type ClientFormValues = { firstName: string; lastName: string; email: string; companyName: string; tags: string[];};
Это не API model. Это именно UI form model.
Почему это важно?
Потому что:
-
Backend меняется;
-
API меняется;
-
Source entities отличаются;
-
UI flow отличается.
Но форма должна оставаться стабильной.
Form context вместо mode prop
Большинство разработчиков начинают с такого API.
<ClientForm mode="edit" />
Проблема в том, что mode очень быстро превращается в giant switch-case.
Гораздо лучше использовать explicit context.
export type ClientFormContext = { type: 'create' | 'edit' | 'create-from-lead' | 'import'; sourceId?: string; readonlyFields?: string[]; allowCompanyEditing: boolean;};
Теперь форма получает не абстрактный mode.
Она получает capabilities.
Это намного важнее.
Создаем orchestration hook
Вся логика формы должна жить здесь.
export const useClientForm = (context: ClientFormContext) => { const defaultValues = useClientDefaultValues(context); const form = useForm<ClientFormValues>({ defaultValues, }); const submit = useClientSubmit({ form, context, }); const capabilities = useClientCapabilities(context); return { form, submit, capabilities, };};
Это центральная точка orchestration.
Именно здесь собирается form runtime.
Компонент формы при этом остается минимальным.
export const ClientForm = ({ context }: Props) => { const { form, submit, capabilities } = useClientForm(context); return ( <FormProvider {...form}> <form onSubmit={submit}> <ClientFields capabilities={capabilities} /> <ClientSubmitButton /> </form> </FormProvider> );};
Никакой бизнес-логики.
Никаких transformations.
Никаких API.
Никаких source entities.
Source adapters — ключевая часть паттерна
Это один из самых важных моментов.
Допустим, у нас есть Lead.
export type Lead = { contact_name: string; contact_email: string; organization: string;};
Но форма клиента работает с:
export type ClientFormValues = { firstName: string; email: string; companyName: string;};
Новички обычно делают mapping прямо внутри компонента.
Это плохая идея.
Правильнее использовать adapter.
export const leadToClientAdapter = (lead: Lead): ClientFormValues => { return { firstName: lead.contact_name, email: lead.contact_email, companyName: lead.organization, };};
Теперь orchestration hook может использовать адаптер.
export const useClientDefaultValues = (context: ClientFormContext) => { const lead = useLead(context.sourceId); return useMemo(() => { switch (context.type) { case 'create': return emptyClientValues; case 'edit': return mapClientToForm(client); case 'create-from-lead': return leadToClientAdapter(lead); default: return emptyClientValues; } }, [context, lead]);};
Теперь form layer полностью отвязан от source entities.
И это критически важно.
Почему это масштабируется
Теперь добавление нового source flow не требует переписывания формы.
Например:
CRM Contact -> ClientCSV Row -> ClientAI Generated Draft -> ClientLinkedIn Import -> Client
Добавляется только:
-
Adapter;
-
Context;
-
Возможно capability configuration.
Сама форма не меняется.
Это главный признак хорошей архитектуры.
Capabilities вместо условной логики
Еще одна огромная проблема enterprise-форм — бесконечные условия.
if (mode === 'edit') { // ...}if (mode === 'create-from-import') { // ...}if (user.role === 'admin') { // ...}
Вместо этого лучше вычислять capabilities.
export type ClientFormCapabilities = { canEditCompany: boolean; canEditEmail: boolean; canAssignManager: boolean;};
И отдельный hook.
export const useClientCapabilities = (context: ClientFormContext): ClientFormCapabilities => { return useMemo(() => { switch (context.type) { case 'import': return { canEditCompany: false, canEditEmail: true, canAssignManager: false, }; case 'edit': return { canEditCompany: true, canEditEmail: false, canAssignManager: true, }; default: return { canEditCompany: true, canEditEmail: true, canAssignManager: false, }; } }, [context]);};
Компоненты становятся предельно простыми.
export const ClientFields = ({ capabilities }: Props) => { return ( <> <TextField name="companyName" disabled={!capabilities.canEditCompany} /> </> );};
Что здесь происходит:
-
Вся условная логика собрана в одном месте — хуке
useClientCapabilities. Это единственная точка, которая знает проcontext.type. -
Компонент не принимает решений. Он получает готовый объект с булевыми флагами и просто включает/выключает поля. Никаких
if (mode === ...)в JSX больше нет. -
Добавление нового сценария — это новый
caseвswitch. UI не трогаем. Поля сами подхватят изменения черезdisabled. -
Типизация защищает от ошибок. Забыли указать
canEditEmailв новом сценарии — TypeScript подсветит сразу, а не после баг-репорта от пользователя. -
Тестирование становится тривиальным. Хук с capabilities — чистая функция от контекста. Никакого монтирования компонентов, никакого
userEvent.click()ради проверки, заблокировано ли поле.
Результат: компонент остаётся тупым, а бизнес-правила живут в изолированном слое — ровно то, ради чего мы разделяли форму на слои.
Где использовать внешний store
Очень важный момент.
Новички часто пытаются хранить весь form state во внешнем store — Zustand, Redux, MobX, Jotai.
Обычно это ошибка.
React Hook Form уже является state manager для формы. Внешний store нужен только для того, что живёт за пределами формы:
-
Сохранение черновика при переходе между страницами;
-
Состояние многошаговой формы;
-
Оптимистичное обновление данных;
-
Общее состояние для нескольких форм одного процесса;
-
Фоновая синхронизация с сервером.
Например:
type ClientDraftStore = { draft: ClientFormValues | null; setDraft: (values: ClientFormValues) => void; clearDraft: () => void;};
export const useClientDraftStore = create<ClientDraftStore>(set => ({ draft: null, setDraft: draft => set({ draft }), clearDraft: () => set({ draft: null }),}));
А synchronization делается через hook.
export const usePersistClientDraft = (form: UseFormReturn<ClientFormValues>) => { const setDraft = useClientDraftStore(state => state.setDraft); useEffect(() => { const subscription = form.watch(values => { setDraft(values as ClientFormValues); }); return () => subscription.unsubscribe(); }, [form, setDraft]);};
Компоненты снова ничего не знают о store.
React и form architecture
React усиливает важность разделения orchestration и rendering.
Если форма — это гигантский мутабельный компонент, в котором всё смешано, React начинает проявлять характер. Всплывают проблемы, которые трудно отлаживать:
-
Гонки состояний: два асинхронных действия обновляют форму, и непонятно, чьё изменение победило.
-
Устаревшие замыкания: колбэк “запомнил” старую версию пропсов и работает с неактуальными данными.
-
Расхождение серверного и клиентского рендера: при SSR форма отрисовала одно, а в браузере — другое.
-
Неуправляемые перерендеры: изменилось что-то одно, а перерисовалось всё, включая поля, которые пользователь только что заполнил.
-
Оптимистичные обновления расходятся с реальностью: показали успех, сервер вернул ошибку, а состояние уже не собрать.
Когда оркестрация вынесена из компонента в хуки, каждая из этих проблем локализуется в одном месте. Её можно найти, протестировать и исправить — не трогая UI.
Как выглядит submit layer
Очень важно отделять submit orchestration.
Плохой вариант:
const onSubmit = async values => { if (mode === 'edit') { await updateClient(values); } if (mode === 'create') { await createClient(values); }};
Что здесь не так:
-
Компонент знает про режимы. Он должен понимать, что такое edit и create, и чем они отличаются. Это не его зона ответственности — он должен просто отрендерить поля и кнопку.
-
Условные ветки будут расти. Добавится create-from-import, invite, duplicate — и каждый раз придётся лезть в компонент и дописывать ещё один
if. -
Сложно тестировать. Чтобы проверить логику сабмита, нужно монтировать весь компонент, заполнять поля, кликать кнопку. Саму логику изолированно не протестировать.
Правильный вариант — submit вынесен в хук:
export const useClientSubmit = ({ form, context }: Params) => { const createMutation = useCreateClient(); const updateMutation = useUpdateClient(); return form.handleSubmit(async values => { switch (context.type) { case 'create': await createMutation.mutateAsync(values); break; case 'edit': await updateMutation.mutateAsync({ id: context.sourceId!, values, }); break; } });};
Что изменилось:
-
Компонент формы вообще не знает, что происходит после submit. Он вызывает переданный ему обработчик — и всё. Никаких
if (mode === ...), никаких знаний о мутациях. -
Добавление нового сценария — это новый case в одном хуке. Компонент не трогаем. Поля не трогаем. Кнопку не трогаем.
-
Хук тестируется изолированно. Передали context с типом
edit— проверили, что дёрнулсяupdateMutation. Никакого DOM, никакого userEvent. -
Мутации тоже изолированы.
useCreateClientиuseUpdateClient— самостоятельные хуки. Их можно переиспользовать в других местах и тестировать отдельно. -
Типизация защищает от ошибок.
context.sourceIdобязателен дляedit, и TypeScript проследит, чтобы вы его передали. В варианте сif (mode === 'edit')легко забыть достать id и получить рантайм-ошибку.
Почему это architectural win:
Раньше сабмит был размазан между компонентом, мутациями и бизнес-логикой. Теперь у него есть чёткое место жительства — хук useClientSubmit. Это единственная точка, которая знает, какой сценарий к какой мутации ведёт. Всё остальное — компоненты, поля, кнопки — остаются в неведении и за счёт этого становятся переиспользуемыми.
Что насчёт MobX и Zustand?
С точки зрения паттерна разницы нет. Разделение на слои не привязано к конкретной библиотеке состояний. Работает с MobX, Zustand, Redux, Jotai — с чем угодно.
Выбор стора обычно продиктован стеком проекта, а не потребностями конкретной формы. И это нормально — паттерн не заставляет ничего менять. Он лишь говорит: что бы вы ни использовали, внешний стор не должен управлять полями формы. Полями управляет React Hook Form. Внешний стор отвечает только за то, что живёт за пределами формы и дольше неё: черновики, состояние мастера, оптимистичные обновления, фоновую синхронизацию.
Но ключевая идея остается одинаковой:
form orchestration не должна жить внутри JSX.
Антипаттерны
Теперь разберем самые частые ошибки.
Giant universal form component
<ClientForm mode="edit" isImport isAdmin isWizard isDrawer isTemplate isExternal />
Это не архитектура. Это ад из пропсов, из которого потом растут сотни if внутри компонента. Добавили новый флаг — переписали половину JSX.
Backend DTO как form model
type ClientDto = { created_at: string; updated_at: string; internal_status: number;};
Форма не должна зависеть от backend representation. У бэка своя жизнь, у интерфейса — своя. Когда форма привязана к DTO, любое изменение на бэке — переименовали поле, добавили служебный флаг, поменяли структуру ответа — тут же ломает UI. Form model должна зависеть только от того, что видит и с чем взаимодействует пользователь.
API calls внутри компонентов
<button onClick={async () => { await api.createClient() }}>
Компоненты должны быть декларативными. Кнопка не должна знать про сеть, эндпоинты и формат запроса. Её дело — сообщить о клике. Всё остальное — снаружи.
Mapping внутри JSX
<input value={lead.contactName} />
Transformation layer должен быть отдельно. Сегодня поле называется contactName, завтра — fullName. Если маппинг размазан по JSX, придётся искать и править каждое вхождение. А если трансформация вынесена в адаптер — достаточно поправить в одном месте.
Глобальный store для всего
Это одна из самых популярных ошибок.
Когда всё состояние формы хранится глобально, начинаются проблемы:
-
Появляются accidental updates;
-
Сложно изолировать flow;
-
Сложно очищать state;
-
Появляются race conditions.
Например React Hook Form уже решает большую часть этих задач. Он хранит состояние полей, управляет валидацией, следит за touched/dirty. Глобальный стор нужен только для того, что живёт за пределами формы.
Почему этот паттерн особенно важен в enterprise
В маленьком приложении можно пережить несколько копий формы.
В enterprise — нет.
Потому что масштаб принципиально другой:
-
Сущностей много. Clients, Orders, Invoices, Templates, Users — и каждая требует create/edit/import. Копировать для каждой — экспоненциальный рост.
-
Workflows много. Одна и та же сущность создаётся из разных точек: с нуля, из лида, из шаблона, через импорт. Каждый flow добавляет новую копию логики.
-
Источников данных много. REST, GraphQL, файлы, внешние API — форма должна собирать данные из разных мест и не зависеть от их формата.
-
Команд много. Разные разработчики трогают одни и те же формы. Без чёткой архитектуры каждый добавляет ещё один if — и через месяц никто не понимает, как форма работает.
-
Онбординг дорогой. Новый разработчик приходит и видит десять копий формы. Чтобы понять логику, нужно прочитать их все. С паттерном — читаешь один хук.
-
Требования постоянно меняются. Сегодня кнопка “Сохранить” просто сохраняет, завтра — запускает approval flow. Если логика размазана по JSX, каждое изменение — риск сломать соседний сценарий.
Context-driven Reusable Form Pattern решает эти проблемы на уровне архитектуры:
-
Единая форма для всех сценариев. Не десять копий, а одна — поведение определяется контекстом.
-
Слабая связанность. Изменение валидации не ломает сабмит, изменение адаптера не трогает UI.
-
Быстрый онбординг. Новый разработчик видит слои, а не месиво из
if. Понятно, куда смотреть и что править. -
Минимум условной логики. Capabilities и контекст заменяют бесконечные
if (mode === ...). -
Переиспользование form runtime. Один и тот же хук сабмита, один и тот же хук валидации, один и тот же presentation layer.
-
Изолированное тестирование. Оркестрация тестируется без рендера, UI тестируется без бизнес-логики.
Как тестировать такую архитектуру
Это ещё одно огромное преимущество — возможно, самое важное.
Когда логика вынесена из компонента в хуки и адаптеры, каждый слой тестируется изолированно:
-
Адаптеры — чистые функции. Передали на вход одно, проверили, что на выходе другое. Никакого DOM, никаких моков API, никакого монтирования компонентов.
-
Capabilities — чистая функция от контекста. Передали
context.type === 'import', проверили, что canEditCompany === false. -
Submit logic — хук, который дёргает мутации. Мокаем
useCreateClient, вызываем сабмит, проверяем, что мутация вызвана с правильными аргументами. -
Orchestration — хук, связывающий всё вместе. Тестируется отдельно от рендера.
Например, адаптер:
describe('leadToClientAdapter', () => { it('maps lead fields correctly', () => { expect(leadToClientAdapter(mockLead)).toEqual({ firstName: 'John', email: 'john@test.com', companyName: 'Acme', }); });});
Или capabilities
describe('useClientCapabilities', () => { it('disables company editing in import mode', () => { const { result } = renderHook(() => useClientCapabilities({ type: 'import' })); expect(result.current.canEditCompany).toBe(false); });});
Тестировать giant form component — это каждый раз монтировать весь компонент, заполнять поля через userEvent, эмулировать клики, ждать асинхронные операции. Один тест на сабмит может занять 30 строк подготовки. А когда логика вынесена — каждый тест это 5 строк и одна проверка. Быстрее писать, легче поддерживать, проще найти, что сломалось.
Эволюция формы в зрелой архитектуре
Обычно зрелая reusable form architecture проходит несколько стадий.
Стадия 1: Отдельные формы.
CreateClientFormEditClientForm
Стадия 2: Universal component.
ClientForm(mode)
Стадия 3: Condition hell.
if editif importif wizardif readonlyif external
Стадия 4: Context-driven architecture.
Form ContextCapabilitiesAdaptersHooksOrchestration Layer
Большинство enterprise React-команд со временем приходят именно к этому.
Потому что complexity растет неизбежно.
Практический итог
Если коротко, то главный принцип можно сформулировать так:
Форма должна быть runtime, а не набором conditionals.
Хорошая reusable form architecture:
-
Не знает про source entities;
-
Не знает про routing;
-
Не знает про API;
-
Не знает про workflow;
-
Не знает про storage.
Она знает только:
-
form values;
-
capabilities;
-
submit contract;
-
validation;
-
UI state.
Всё остальное — Orchestration layer.
Именно это позволяет масштабировать React-приложения без бесконечного копирования форм.
Заключение
Context-driven Reusable Form — это не библиотека, не фреймворк и не стандарт из учебника. Это название появилось, чтобы описать решение для новичков: как перестать копировать формы, убрать бесконечные if (mode === ...) и построить архитектуру, которая масштабируется.
Это архитектурный подход, который работает в реальных проектах:
-
CRM;
-
ERP;
-
admin systems;
-
SaaS platforms;
-
internal tools;
-
enterprise dashboards.
Главная идея очень простая:
-
UI должен быть dumb — только отображать state;
-
Оркестрация должна жить в хуках — не в JSX;
-
Трансформация данных — в адаптерах (lib/utils), не в компонентах;
-
Capabilities должны вычисляться отдельно;
-
Контекст должен описывать intent — зачем открыта форма, а не просто
mode.
Именно такой подход позволяет строить формы, которые не разваливаются через полгода разработки.
А в больших React приложениях это уже не просто “хорошая практика”, а практически обязательное условие поддерживаемой архитектуры.
ссылка на оригинал статьи https://habr.com/ru/articles/1038754/