Context-driven Reusable Form Pattern: Масштабируемая архитектура для Create / Edit / Create-from-So…
- вторник, 26 мая 2026 г. в 00:00:11
Как перестать копировать формы и построить масштабируемую архитектуру 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 -> Client Order -> Invoice Template -> Document ImportRow -> 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-интеграция;
Роутинг;
Аналитика;
Именно этот винегрет делает формы нерасширяемыми.
Основная суть паттерна, это разделить форму на несколько независимых слоев.
Компоненты ничего не знают о:
Роутинге;
Сущностях;
Source entity;
Create / Edit;
REST;
GraphQL;
Zustand;
MobX.
Они только отображают state.
Пользовательский hook управляет:
Form state;
Submit;
Hydration;
Side effects;
Transformations.
Контекст определяет:
Зачем открыта форма;
Какие capabilities доступны;
Откуда пришли initial values;
Какие ограничения действуют.
Source adapters преобразуют внешние сущности в form model.
Например:
Lead -> ClientDraft ImportRow -> ClientDraft Template -> 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, где композиция становится центральной частью архитектуры, а чистота компонентов — залогом переиспользования.
Формы, которые работают только в среду — это не фича, это диагноз.
Самая распространённая ошибка новичков — взять 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 отличается.
Но форма должна оставаться стабильной.
Большинство разработчиков начинают с такого 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.
Это намного важнее.
Вся логика формы должна жить здесь.
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.
Это один из самых важных моментов.
Допустим, у нас есть 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 -> Client CSV Row -> Client AI Generated Draft -> Client LinkedIn Import -> Client
Добавляется только:
Adapter;
Context;
Возможно capability configuration.
Сама форма не меняется.
Это главный признак хорошей архитектуры.
Еще одна огромная проблема 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() ради проверки, заблокировано ли поле.
Результат: компонент остаётся тупым, а бизнес-правила живут в изолированном слое — ровно то, ради чего мы разделяли форму на слои.
Очень важный момент.
Новички часто пытаются хранить весь 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 усиливает важность разделения orchestration и rendering.
Если форма — это гигантский мутабельный компонент, в котором всё смешано, React начинает проявлять характер. Всплывают проблемы, которые трудно отлаживать:
Гонки состояний: два асинхронных действия обновляют форму, и непонятно, чьё изменение победило.
Устаревшие замыкания: колбэк “запомнил” старую версию пропсов и работает с неактуальными данными.
Расхождение серверного и клиентского рендера: при SSR форма отрисовала одно, а в браузере — другое.
Неуправляемые перерендеры: изменилось что-то одно, а перерисовалось всё, включая поля, которые пользователь только что заполнил.
Оптимистичные обновления расходятся с реальностью: показали успех, сервер вернул ошибку, а состояние уже не собрать.
Когда оркестрация вынесена из компонента в хуки, каждая из этих проблем локализуется в одном месте. Её можно найти, протестировать и исправить — не трогая UI.
Очень важно отделять 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, Redux, Jotai — с чем угодно.
Выбор стора обычно продиктован стеком проекта, а не потребностями конкретной формы. И это нормально — паттерн не заставляет ничего менять. Он лишь говорит: что бы вы ни использовали, внешний стор не должен управлять полями формы. Полями управляет React Hook Form. Внешний стор отвечает только за то, что живёт за пределами формы и дольше неё: черновики, состояние мастера, оптимистичные обновления, фоновую синхронизацию.
Но ключевая идея остается одинаковой:
form orchestration не должна жить внутри JSX.
Теперь разберем самые частые ошибки.
<ClientForm mode="edit" isImport isAdmin isWizard isDrawer isTemplate isExternal />
Это не архитектура. Это ад из пропсов, из которого потом растут сотни if внутри компонента. Добавили новый флаг — переписали половину JSX.
type ClientDto = { created_at: string; updated_at: string; internal_status: number; };
Форма не должна зависеть от backend representation. У бэка своя жизнь, у интерфейса — своя. Когда форма привязана к DTO, любое изменение на бэке — переименовали поле, добавили служебный флаг, поменяли структуру ответа — тут же ломает UI. Form model должна зависеть только от того, что видит и с чем взаимодействует пользователь.
<button onClick={async () => { await api.createClient() }} >
Компоненты должны быть декларативными. Кнопка не должна знать про сеть, эндпоинты и формат запроса. Её дело — сообщить о клике. Всё остальное — снаружи.
<input value={lead.contactName} />
Transformation layer должен быть отдельно. Сегодня поле называется contactName, завтра — fullName. Если маппинг размазан по JSX, придётся искать и править каждое вхождение. А если трансформация вынесена в адаптер — достаточно поправить в одном месте.
Это одна из самых популярных ошибок.
Когда всё состояние формы хранится глобально, начинаются проблемы:
Появляются accidental updates;
Сложно изолировать flow;
Сложно очищать state;
Появляются race conditions.
Например React Hook Form уже решает большую часть этих задач. Он хранит состояние полей, управляет валидацией, следит за touched/dirty. Глобальный стор нужен только для того, что живёт за пределами формы.
В маленьком приложении можно пережить несколько копий формы.
В 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 проходит несколько стадий.
CreateClientForm EditClientForm
ClientForm(mode)
if edit if import if wizard if readonly if external
Form Context Capabilities Adapters Hooks Orchestration 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 приложениях это уже не просто “хорошая практика”, а практически обязательное условие поддерживаемой архитектуры.