Stop Copying React Forms: A Scalable Pattern for Create, Edit, and Duplicate Flows
A practical React architecture for separating form UI, orchestration, adapters, capabilities, and submit logic.
Stop Treating Every Form Flow as a New Form
Most React applications start with a simple form.
You need to create a customer, product, invoice, document, or user. So you build a form component, connect a submit handler, add validation, and ship it.
At first, everything looks fine.
export function CreateCustomerForm() {
return (
<form>
<input name="firstName" />
<input name="email" />
<button type="submit">Create customer</button>
</form>
);
}Then the product grows.
Now users need to edit the same customer. Later, they need to create a customer from a lead. Then from an imported CSV row. Then from a template. Then inside a drawer. Then inside a wizard. Then from an external API response.
Before long, the codebase contains something like this:
CreateCustomerForm
EditCustomerForm
DuplicateCustomerForm
CreateCustomerFromLeadForm
ImportCustomerForm
QuickCreateCustomerForm
CustomerDrawerFormThe problem is not that there are many forms. The real problem is that each form owns a slightly different copy of the same logic: initial values, validation, permissions, field visibility, submit behavior, API mapping, and side effects.
That does not scale.
A better approach is to make the form reusable, but not by turning it into a giant component with a mode prop. The goal is to make the form a rendering layer, while the behavior comes from a separate runtime context.
The core idea is simple:
The form should not know where the data came from or why it was opened.
A good reusable form should only know about:
form values
validation
capabilities
submit handler
UI stateEverything else should live outside the component.
The Trap of the Universal mode Prop
A common first attempt looks like this:
<CustomerForm mode="edit" />This feels clean for about five minutes.
Then the component starts growing:
if (mode === 'edit') {
// load existing customer
}
if (mode === 'create-from-lead') {
// map lead fields
}
if (mode === 'import') {
// lock some fields
}
if (mode === 'duplicate') {
// remove id and reset status
}Now the component is no longer a form. It is a workflow engine hidden inside JSX.
The UI, data loading, permissions, validation, mapping, and submit logic all live in one place. That makes the form hard to reuse, hard to test, and dangerous to change.
Instead of passing a vague mode, pass a context object that describes the intent and capabilities of the current flow.
export type CustomerFormContext =
| {
kind: 'create';
}
| {
kind: 'edit';
customerId: string;
}
| {
kind: 'createFromLead';
leadId: string;
}
| {
kind: 'importFromCsv';
rowId: string;
}
| {
kind: 'duplicate';
customerId: string;
};This is already better than a plain string. TypeScript now knows which fields belong to which scenario.
For example, customerId exists for edit and duplicate, but not for create.
That matters.
Start With a Form Model
One of the biggest mistakes in form architecture is using backend DTOs as form state.
Do not do this:
export type CustomerDto = {
id: string;
first_name: string;
last_name: string;
email_address: string;
created_at: string;
updated_at: string;
internal_status: number;
};A form model should belong to the UI, not the backend.
export type CustomerFormValues = {
firstName: string;
lastName: string;
email: string;
companyName: string;
tags: string[];
};The form model should represent what the user can see and edit.
Backend models change. API responses change. Database fields change. External sources use different naming. But the form should stay stable.
That is why every serious form architecture needs mapping layers.
Split the Form Into Layers
A scalable React form usually needs four layers.
1. Presentation Layer
This is the visual part. It renders fields, buttons, labels, errors, and layout.
It should not know about:
routes
API clients
backend DTOs
lead objects
CSV rows
permissions logic
mutation details
workflow rulesIt receives props and renders UI.
2. Orchestration Layer
This is usually a custom hook.
It wires together:
default values
React Hook Form
validation
capabilities
submit handler
side effects3. Context Layer
This describes why the form exists right now.
Is this a create flow? Edit flow? Duplicate flow? Import flow? Source-based flow?
The context should describe intent.
4. Adapter Layer
Adapters transform external data into the form model.
For example:
Lead -> CustomerFormValues
CSV Row -> CustomerFormValues
Customer DTO -> CustomerFormValues
Template -> CustomerFormValuesThe form never consumes these external models directly.
A Practical Folder Structure
For a customer feature, the structure can look like this:
src/features/customers/
├── api/
│ ├── create-customer.ts
│ ├── update-customer.ts
│ └── get-customer.ts
├── adapters/
│ ├── customer-to-form-values.ts
│ ├── lead-to-customer-form-values.ts
│ └── csv-row-to-customer-form-values.ts
├── components/
│ ├── customer-form.tsx
│ ├── customer-fields.tsx
│ ├── customer-submit-button.tsx
│ └── text-field.tsx
├── hooks/
│ ├── use-customer-form.ts
│ ├── use-customer-default-values.ts
│ ├── use-customer-capabilities.ts
│ └── use-customer-submit.ts
├── schemas/
│ └── customer-form-schema.ts
└── types/
└── customer-form-types.tsThe important part is not the exact folder names. The important part is the separation.
Components render.
Hooks orchestrate.
Adapters transform.
Schemas validate.
API functions talk to the server.
Define the Types
export type CustomerFormValues = {
firstName: string;
lastName: string;
email: string;
companyName: string;
tags: string[];
};
export type CustomerFormContext =
| {
kind: 'create';
}
| {
kind: 'edit';
customerId: string;
}
| {
kind: 'createFromLead';
leadId: string;
}
| {
kind: 'importFromCsv';
rowId: string;
}
| {
kind: 'duplicate';
customerId: string;
};
export type CustomerFormCapabilities = {
canEditEmail: boolean;
canEditCompanyName: boolean;
canAssignTags: boolean;
submitLabel: string;
};Notice that the capabilities are explicit.
The component does not ask:
context.kind === 'edit'It asks:
capabilities.canEditEmailThat is a big architectural difference.
Create Empty Default Values
export const emptyCustomerFormValues: CustomerFormValues = {
firstName: '',
lastName: '',
email: '',
companyName: '',
tags: [],
};This gives every flow a stable fallback.
Never let the form boot with random undefined values if the fields are expected to be controlled.
Add Adapters
Imagine the backend customer looks like this:
export type CustomerDto = {
id: string;
first_name: string;
last_name: string;
email_address: string;
company_name: string;
tag_names: string[];
};Create a mapper:
import type { CustomerFormValues } from '../types/customer-form-types';
type CustomerDto = {
id: string;
first_name: string;
last_name: string;
email_address: string;
company_name: string;
tag_names: string[];
};
export function customerToFormValues(
customer: CustomerDto,
): CustomerFormValues {
return {
firstName: customer.first_name,
lastName: customer.last_name,
email: customer.email_address,
companyName: customer.company_name,
tags: customer.tag_names,
};
}Now imagine a lead has a different shape:
export type LeadDto = {
contact_name: string;
contact_email: string;
organization_name: string;
};Create another adapter:
import type { CustomerFormValues } from '../types/customer-form-types';
type LeadDto = {
contact_name: string;
contact_email: string;
organization_name: string;
};
export function leadToCustomerFormValues(
lead: LeadDto,
): CustomerFormValues {
const [firstName = '', ...rest] = lead.contact_name.trim().split(' ');
return {
firstName,
lastName: rest.join(' '),
email: lead.contact_email,
companyName: lead.organization_name,
tags: [],
};
}The form does not care that the source was a lead.
It only receives CustomerFormValues.
Compute Capabilities Separately
Instead of spreading conditions across JSX, centralize them.
import { useMemo } from 'react';
import type {
CustomerFormCapabilities,
CustomerFormContext,
} from '../types/customer-form-types';
export function useCustomerCapabilities(
context: CustomerFormContext,
): CustomerFormCapabilities {
return useMemo(() => {
switch (context.kind) {
case 'edit':
return {
canEditEmail: false,
canEditCompanyName: true,
canAssignTags: true,
submitLabel: 'Save changes',
};
case 'createFromLead':
return {
canEditEmail: true,
canEditCompanyName: true,
canAssignTags: false,
submitLabel: 'Create from lead',
};
case 'importFromCsv':
return {
canEditEmail: true,
canEditCompanyName: false,
canAssignTags: false,
submitLabel: 'Import customer',
};
case 'duplicate':
return {
canEditEmail: true,
canEditCompanyName: true,
canAssignTags: true,
submitLabel: 'Create copy',
};
case 'create':
return {
canEditEmail: true,
canEditCompanyName: true,
canAssignTags: true,
submitLabel: 'Create customer',
};
}
}, [context]);
}Now the UI is simple.
type CustomerFieldsProps = {
capabilities: CustomerFormCapabilities;
};
export function CustomerFields({ capabilities }: CustomerFieldsProps) {
return (
<>
<TextField name="firstName" label="First name" />
<TextField name="lastName" label="Last name" />
<TextField
name="email"
label="Email"
disabled={!capabilities.canEditEmail}
/>
<TextField
name="companyName"
label="Company"
disabled={!capabilities.canEditCompanyName}
/>
{capabilities.canAssignTags ? (
<TextField name="tags" label="Tags" />
) : null}
</>
);
}The component does not know about edit, import, or duplicate.
It only knows what the user is allowed to do.
Build a Small Field Component
Here is a simple React Hook Form field component:
import { useFormContext } from 'react-hook-form';
import type { CustomerFormValues } from '../types/customer-form-types';
type TextFieldProps = {
name: keyof CustomerFormValues;
label: string;
disabled?: boolean;
};
export function TextField({ name, label, disabled }: TextFieldProps) {
const {
register,
formState: { errors },
} = useFormContext<CustomerFormValues>();
const errorMessage = errors[name]?.message;
return (
<label>
<span>{label}</span>
<input
{...register(name)}
disabled={disabled}
aria-invalid={Boolean(errorMessage)}
/>
{typeof errorMessage === 'string' ? (
<small role="alert">{errorMessage}</small>
) : null}
</label>
);
}This component is intentionally boring.
That is good.
Reusable form components should be boring.
Load Default Values in a Hook
The form should not fetch data directly inside JSX.
Create a hook for default values.
import { useMemo } from 'react';
import { customerToFormValues } from '../adapters/customer-to-form-values';
import { leadToCustomerFormValues } from '../adapters/lead-to-customer-form-values';
import { emptyCustomerFormValues } from '../types/customer-form-defaults';
import type {
CustomerFormContext,
CustomerFormValues,
} from '../types/customer-form-types';
import { useCustomerQuery } from '../api/use-customer-query';
import { useLeadQuery } from '../api/use-lead-query';
import { useCsvRowQuery } from '../api/use-csv-row-query';
export function useCustomerDefaultValues(
context: CustomerFormContext,
): CustomerFormValues {
const customerQuery = useCustomerQuery(
context.kind === 'edit' || context.kind === 'duplicate'
? context.customerId
: null,
);
const leadQuery = useLeadQuery(
context.kind === 'createFromLead' ? context.leadId : null,
);
const csvRowQuery = useCsvRowQuery(
context.kind === 'importFromCsv' ? context.rowId : null,
);
return useMemo(() => {
switch (context.kind) {
case 'create':
return emptyCustomerFormValues;
case 'edit':
return customerQuery.data
? customerToFormValues(customerQuery.data)
: emptyCustomerFormValues;
case 'duplicate':
return customerQuery.data
? {
...customerToFormValues(customerQuery.data),
email: '',
}
: emptyCustomerFormValues;
case 'createFromLead':
return leadQuery.data
? leadToCustomerFormValues(leadQuery.data)
: emptyCustomerFormValues;
case 'importFromCsv':
return csvRowQuery.data
? {
firstName: csvRowQuery.data.firstName ?? '',
lastName: csvRowQuery.data.lastName ?? '',
email: csvRowQuery.data.email ?? '',
companyName: csvRowQuery.data.company ?? '',
tags: [],
}
: emptyCustomerFormValues;
}
}, [context, customerQuery.data, leadQuery.data, csvRowQuery.data]);
}In a real app, you may also return loading and error states from this hook.
The important part is that the component still does not know how values are created.
Add Submit Logic in Its Own Hook
Bad submit logic usually looks like this:
async function onSubmit(values: CustomerFormValues) {
if (mode === 'create') {
await createCustomer(values);
}
if (mode === 'edit') {
await updateCustomer(values);
}
if (mode === 'duplicate') {
await createCustomer(values);
}
}This grows badly.
Instead, isolate it:
import type { UseFormReturn } from 'react-hook-form';
import type {
CustomerFormContext,
CustomerFormValues,
} from '../types/customer-form-types';
import { useCreateCustomerMutation } from '../api/use-create-customer-mutation';
import { useUpdateCustomerMutation } from '../api/use-update-customer-mutation';
type UseCustomerSubmitParams = {
form: UseFormReturn<CustomerFormValues>;
context: CustomerFormContext;
};
export function useCustomerSubmit({
form,
context,
}: UseCustomerSubmitParams) {
const createCustomer = useCreateCustomerMutation();
const updateCustomer = useUpdateCustomerMutation();
return form.handleSubmit(async values => {
switch (context.kind) {
case 'create':
case 'createFromLead':
case 'importFromCsv':
case 'duplicate':
await createCustomer.mutateAsync(values);
return;
case 'edit':
await updateCustomer.mutateAsync({
customerId: context.customerId,
values,
});
return;
}
});
}Now the submit rules live in one place.
Adding a new flow does not require editing the form component.
Create the Orchestration Hook
Now combine everything.
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import type {
CustomerFormContext,
CustomerFormValues,
} from '../types/customer-form-types';
import { useCustomerCapabilities } from './use-customer-capabilities';
import { useCustomerDefaultValues } from './use-customer-default-values';
import { useCustomerSubmit } from './use-customer-submit';
export function useCustomerForm(context: CustomerFormContext) {
const defaultValues = useCustomerDefaultValues(context);
const capabilities = useCustomerCapabilities(context);
const form = useForm<CustomerFormValues>({
defaultValues,
});
useEffect(() => {
form.reset(defaultValues);
}, [form, defaultValues]);
const onSubmit = useCustomerSubmit({
form,
context,
});
return {
form,
onSubmit,
capabilities,
};
}This hook is the form runtime.
It creates the form instance, applies default values, computes capabilities, and returns the submit handler.
The component can stay clean.
Build the Final Form Component
import { FormProvider } from 'react-hook-form';
import type { CustomerFormContext } from '../types/customer-form-types';
import { useCustomerForm } from '../hooks/use-customer-form';
import { CustomerFields } from './customer-fields';
type CustomerFormProps = {
context: CustomerFormContext;
};
export function CustomerForm({ context }: CustomerFormProps) {
const { form, onSubmit, capabilities } = useCustomerForm(context);
return (
<FormProvider {...form}>
<form onSubmit={onSubmit}>
<CustomerFields capabilities={capabilities} />
<button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting
? 'Saving...'
: capabilities.submitLabel}
</button>
</form>
</FormProvider>
);
}This is the result we wanted.
The form component does not know about the API. It does not know about leads. It does not know about CSV rows. It does not know which mutation will run.
It renders fields and submits.
That is all.
Use the Same Form in Different Flows
Create flow:
export function CreateCustomerPage() {
return <CustomerForm context={{ kind: 'create' }} />;
}Edit flow:
type EditCustomerPageProps = {
customerId: string;
};
export function EditCustomerPage({ customerId }: EditCustomerPageProps) {
return (
<CustomerForm
context={{
kind: 'edit',
customerId,
}}
/>
);
}Create from lead:
type CreateFromLeadPageProps = {
leadId: string;
};
export function CreateFromLeadPage({ leadId }: CreateFromLeadPageProps) {
return (
<CustomerForm
context={{
kind: 'createFromLead',
leadId,
}}
/>
);
}Duplicate:
type DuplicateCustomerPageProps = {
customerId: string;
};
export function DuplicateCustomerPage({
customerId,
}: DuplicateCustomerPageProps) {
return (
<CustomerForm
context={{
kind: 'duplicate',
customerId,
}}
/>
);
}One form.
Multiple workflows.
No duplicated JSX.
Where External Stores Fit
A common mistake is putting the entire form state into Zustand, Redux, MobX, or another global store.
Usually, you do not need that.
React Hook Form already manages form state. It tracks values, dirty fields, touched fields, validation, and submission state.
External stores are useful only for state that must live outside the form lifecycle.
Good examples:
multi-step wizard progress
draft persistence
shared state between several forms
optimistic updates
background sync
cross-page form recoveryFor example, draft persistence can live outside the form.
import { create } from 'zustand';
import type { CustomerFormValues } from '../types/customer-form-types';
type CustomerDraftStore = {
draft: CustomerFormValues | null;
saveDraft: (values: CustomerFormValues) => void;
clearDraft: () => void;
};
export const useCustomerDraftStore = create<CustomerDraftStore>(set => ({
draft: null,
saveDraft: draft => set({ draft }),
clearDraft: () => set({ draft: null }),
}));Then synchronize it with a hook:
import { useEffect } from 'react';
import type { UseFormReturn } from 'react-hook-form';
import { useCustomerDraftStore } from '../stores/customer-draft-store';
import type { CustomerFormValues } from '../types/customer-form-types';
export function usePersistCustomerDraft(
form: UseFormReturn<CustomerFormValues>,
) {
const saveDraft = useCustomerDraftStore(state => state.saveDraft);
useEffect(() => {
const subscription = form.watch(values => {
saveDraft(values as CustomerFormValues);
});
return () => {
subscription.unsubscribe();
};
}, [form, saveDraft]);
}Again, the component does not know about the store.
That is the pattern.
Validation Belongs to the Form Runtime
Validation should also be part of the form layer, not scattered across components.
With Zod, the schema can look like this:
import { z } from 'zod';
export const customerFormSchema = z.object({
firstName: z.string().min(1, 'First name is required'),
lastName: z.string().min(1, 'Last name is required'),
email: z.string().email('Enter a valid email address'),
companyName: z.string().min(1, 'Company name is required'),
tags: z.array(z.string()),
});
export type CustomerFormValues = z.infer<typeof customerFormSchema>;Then connect it to React Hook Form:
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { customerFormSchema } from '../schemas/customer-form-schema';
const form = useForm<CustomerFormValues>({
defaultValues,
resolver: zodResolver(customerFormSchema),
});If validation differs by context, keep that logic centralized too.
export function getCustomerFormSchema(context: CustomerFormContext) {
if (context.kind === 'importFromCsv') {
return customerFormSchema.omit({
companyName: true,
});
}
return customerFormSchema;
}Then use it inside the orchestration hook.
Why This Scales
This architecture scales because each new workflow adds a small isolated piece instead of changing the entire form.
If tomorrow you need:
HubSpot Contact -> Customer
AI Draft -> Customer
Template -> Customer
Archived Customer -> Duplicate
Invite -> CustomerYou do not rewrite the form.
You add:
new context variant
new adapter
new capability configuration
new submit branch if neededThe presentation layer stays stable.
That is the point.
Anti-Patterns to Avoid
Giant Universal Components
<CustomerForm
mode="edit"
isAdmin
isDrawer
isWizard
isImport
isReadonly
isExternal
/>This is not reusable architecture. This is conditional chaos.
Backend DTOs as Form State
type FormValues = CustomerDto;The form should not depend on backend representation.
API Calls Inside Field Components
<button
onClick={async () => {
await api.customers.create();
}}
>
Save
</button>Buttons should not know about endpoints.
Mapping Inside JSX
<input value={lead.contact_email} />Mapping belongs in adapters.
Global Store for Every Field
Do not turn every keystroke into global application state unless there is a real reason.
React Hook Form should own field state.
Testing Becomes Easier
This architecture is much easier to test because logic is isolated.
Adapters are pure functions:
import { leadToCustomerFormValues } from './lead-to-customer-form-values';
it('maps lead data to customer form values', () => {
const result = leadToCustomerFormValues({
contact_name: 'Ada Lovelace',
contact_email: 'ada@example.com',
organization_name: 'Analytical Engines Ltd',
});
expect(result).toEqual({
firstName: 'Ada',
lastName: 'Lovelace',
email: 'ada@example.com',
companyName: 'Analytical Engines Ltd',
tags: [],
});
});Capabilities are easy to test:
import { renderHook } from '@testing-library/react';
import { useCustomerCapabilities } from './use-customer-capabilities';
it('locks company editing during CSV import', () => {
const { result } = renderHook(() =>
useCustomerCapabilities({
kind: 'importFromCsv',
rowId: 'row_123',
}),
);
expect(result.current.canEditCompanyName).toBe(false);
});You do not need to mount a huge form just to test one business rule.
That is a major win.
The Real Goal: Make the Form Boring
The best reusable forms are not clever.
They are predictable.
A mature React form architecture has a clear rule:
UI renders.
Hooks orchestrate.
Adapters transform.
Schemas validate.
Mutations submit.
Context describes intent.
Capabilities describe permissions.When these responsibilities are separated, the form becomes easier to extend.
You can add new workflows without duplicating JSX. You can update backend mapping without touching fields. You can change permissions without editing inputs. You can test business rules without rendering the whole form.
That is the difference between a reusable component and a reusable architecture.
Conclusion
Reusable forms are not about having one massive component that handles every possible case.
They are about designing a small runtime around a stable form model.
The form itself should not care whether it is creating, editing, duplicating, importing, or creating from another entity. It should receive values, capabilities, validation, and a submit handler.
Everything else belongs outside.
For small projects, this may feel like extra structure. For enterprise React applications, admin panels, CRMs, SaaS dashboards, and internal tools, it quickly becomes necessary.
Because once forms start multiplying, copy-paste becomes expensive.
A context-driven reusable form pattern helps keep that complexity under control.
One form.
Many workflows.
No condition hell.


