Welcome back to the React Mastery Series!
In Day 33, we designed a production-ready API architecture with:
- Service layers
- Axios and Fetch
- Interceptors
- Authentication
- Error handling
- Request cancellation
- Retry strategies
- TypeScript API contracts
- TanStack Query
Today, we're moving to another area that looks simple initially but becomes surprisingly complex in enterprise applications:
Forms
A simple form might contain:
Name
Email
Password
Submit
But a real-world enterprise form can contain:
100+ fields
Dynamic sections
Conditional fields
Nested objects
File uploads
Async validation
Server-side validation
Multi-step workflows
Dependent fields
Complex business rules
At this scale, form architecture matters.
Controlled vs Uncontrolled Components
Before looking at libraries, we need to understand how React handles form state.
Controlled Component
React owns the input value.
function LoginForm() {
const [email, setEmail] = useState("");
return (
<input
value={email}
onChange={(event) =>
setEmail(event.target.value)
}
/>
);
}
The flow is:
User Input
↓
onChange
↓
React State
↓
Component Re-render
↓
Input Value
This provides complete control.
Advantages of Controlled Components
Controlled inputs are useful when you need:
- Immediate UI updates
- Conditional rendering
- Custom formatting
- Real-time validation
- Input-dependent behavior
For example:
{accountType === "business" && ( <BusinessDetails />)}
The Problem at Scale
Imagine a form containing:
200 Inputs
If every keystroke updates React state:
User types
↓
State update
↓
Component render
↓
200 fields potentially participate
For small forms, this is usually fine.
For very large forms, it can become expensive if the component tree isn't designed carefully.
Uncontrolled Components
With an uncontrolled input, the DOM maintains the current value.
function LoginForm() {
const emailRef = useRef<HTMLInputElement>(null);
return (
<input ref={emailRef} />
);
}
You can read the value when required:
const email = emailRef.current?.value;
The React state doesn't update on every keystroke.
React Hook Form
For complex forms, React Hook Form is a popular choice because it is designed around efficient form handling and uncontrolled inputs.
Basic example:
import { useForm } from "react-hook-form";
type LoginForm = {
email: string;
password: string;
};
function Login() {
const { register, handleSubmit } = useForm<LoginForm>();
const onSubmit = ( data: LoginForm ) => {
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)} >
<input {...register("email")} />
<input type="password" {...register("password")} />
<button type="submit"> Login </button>
</form>
);
}
The library manages much of the form state and validation lifecycle.
Why React Hook Form?
It provides useful capabilities such as:
- Form registration
- Validation
- Error management
- Submission handling
- Field arrays
- Watchers
- Form state
- Controlled component integration
It also minimizes unnecessary re-renders in many form scenarios.
Validation
A form isn't complete just because it collects data.
We need to validate it.
Example:
const {register,handleSubmit,formState:{ errors}} = useForm<LoginForm>();
Then:
<input {...register("email", { required: "Email is required"})}
/>
Display the error:
{errors.email && ( <p>{errors.email.message}</p>)}
Validation Rules
We can add multiple rules.
<input
{...register("password", {
required: "Password is required",
minLength: {
value: 8,
message:
"Password must contain at least 8 characters",
},
})}
/>
This works well for simple validation.
But enterprise forms often require more complex schemas.
Schema Validation
A common approach is to define the validation rules separately from the UI.
For example, using Zod:
import { z } from "zod";
const loginSchema = z.object({
email: z
.string()
.email("Invalid email address"),
password: z
.string()
.min(
8,
"Password must contain at least 8 characters"
),
});
Now the validation rules are centralized.
Integrating Zod with React Hook Form
import { zodResolver } from "@hookform/resolvers/zod";
const {
register,
handleSubmit,
formState: {
errors
}
} = useForm<LoginForm>({
resolver: zodResolver(
loginSchema
),
});
Now React Hook Form uses the Zod schema to validate the form.
Type Inference
One of the advantages of schema-based validation is that we can derive TypeScript types.
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
type LoginForm =
z.infer<typeof loginSchema>;
Now the validation schema and TypeScript model stay aligned.
Form Architecture
Instead of putting everything inside one component:
LoginForm.tsx
500 lines
split responsibilities.
features
└── auth
└── forms
└── login
├── LoginForm.tsx
├── loginSchema.ts
├── login.types.ts
└── login.service.ts
This becomes much easier to maintain.
Multi-Step Forms
Enterprise workflows often use multiple steps.
Example:
Step 1: Personal Information
↓
Step 2: Address
↓
Step 3 Employment
↓
Step 4: Review
↓
Step 5: Submit
The key architectural question is:
Where should the state live?
For a multi-step form, the form state often needs to survive navigation between steps.
Example Multi-Step Structure
registration
├── RegistrationForm.tsx
├── steps
│ ├── PersonalDetails.tsx
│ ├── AddressDetails.tsx
│ ├── EmploymentDetails.tsx
│ └── ReviewDetails.tsx
├── schema
│ └── registrationSchema.ts
└── types
└── registration.types.ts
Each step owns its UI.
The parent owns the overall workflow.
Conditional Fields
Real-world forms frequently contain conditional fields.
Example:
Account Type
Individual
Business
If the user selects:
Business
show:
Company Name
Registration Number
Tax ID
Example:
{accountType === "business" && (
<>
<input
{...register(
"companyName"
)}
/>
<input
{...register(
"registrationNumber"
)}
/>
</>
)}
Dependent Fields
Consider:
Country
↓
State
↓
City
Changing the country should update the available states.
Changing the state should update the cities.
This creates a dependency chain:
Country
│
▼
States
│
▼
Cities
These dependencies should be handled explicitly rather than scattered across many components.
Dynamic Fields
Suppose a customer can add multiple beneficiaries.
Initially:
Beneficiary 1
The user clicks:
+ Add Beneficiary
Now:
Beneficiary 1
Beneficiary 2
React Hook Form provides useFieldArray for this scenario.
const {fields,append,remove} = useFieldArray({
control,
name: "beneficiaries",
});
Render:
{fields.map(
(field, index) => (
<div key={field.id}>
<input
{...register(
`beneficiaries.${index}.name`
)}
/>
<button
type="button"
onClick={() =>
remove(index)
}
>
Remove
</button>
</div>
)
)}
This is extremely useful for enterprise forms.
Nested Forms
Consider an object:
type Customer = {
name: string;
address: {
street: string;
city: string;
country: string;
};
};
The form can represent the nested structure:
<input
{...register(
"address.street"
)}
/>
<input
{...register(
"address.city"
)}
/>
<input
{...register(
"address.country"
)}
/>
The resulting data remains structured.
Async Validation
Some validations require a backend call.
Example:
Username
↓
Check Availability
↓
Available / Already Exists
Or:
Customer ID
↓
Backend Verification
↓
Valid / Invalid
This should be treated differently from simple synchronous validation.
Server-Side Validation
Frontend validation isn't enough.
Imagine:
Frontend
↓
Amount <= 100,000
↓
Submit
But the backend responds:
Transaction limit exceeded
The server remains the final authority for business rules.
Therefore:
Client Validation
+
Server Validation
are both necessary.
Handling API Errors
Suppose the backend returns:
{
"field": "email",
"message": "Email already exists"
}
The form should ideally map this error to the appropriate field.
Conceptually:
setError("email", {
type: "server",
message:
"Email already exists",
});
Now the user sees the error next to the correct field.
Form-Level Errors
Some errors don't belong to a specific field.
For example:
Unable to process your request.
Please try again later.
This is a form-level error.
Keep these separate from field-level validation.
Field Error
↓
Specific input
Form Error
↓
General submission problem
Submission State
A production form should clearly represent:
Idle
↓
Submitting
↓
Success
or:
Idle
↓
Submitting
↓
Error
Example:
const {
formState: {
isSubmitting,
},
} = useForm();
Disable the submit button while the request is processing:
<button type="submit" disabled={isSubmitting}
>
{isSubmitting ? "Submitting..." : "Submit"}
</button>
This also helps prevent accidental duplicate submissions.
Preventing Duplicate Submissions
Imagine a payment form:
User clicks Submit
↓
Request starts
↓
User clicks Submit again
↓
Second request
This can create serious business problems.
A good form architecture should:
- Disable submission while processing
- Handle idempotency where required
- Show clear progress
- Handle retries carefully
Form Reset
After successful submission:
reset();
But don't blindly reset forms.
For example, if a server validation error occurs:
Submission Failed
the user should normally retain their input.
Reset only when the workflow requires it.
Dirty State
Enterprise applications often need to detect unsaved changes.
For example:
User edits profile
↓
Clicks Back
↓
"Are you sure you want to leave?"
React Hook Form exposes information such as:
formState.isDirty
which can help implement this behavior.
Autosave
Some applications require automatic saving.
For example:
User edits application
↓
Wait 2 seconds
↓
Save Draft
This can be implemented with:
- Debouncing
- Mutation APIs
- Dirty-state tracking
- Optimistic UI where appropriate
But autosave should be designed carefully to avoid excessive network traffic.
File Uploads
Enterprise forms may include:
Passport
Salary Certificate
Bank Statement
Profile Photo
File uploads introduce additional concerns:
- File size
- File type
- Upload progress
- Security
- Cancellation
- Retry
- Server validation
Don't assume a file selected in the browser is automatically safe.
The backend must validate uploaded content as well.
Accessibility
Form architecture isn't only about state management.
Forms must also be accessible.
Use:
<label htmlFor="email">
Email
</label>
<input id="email" {...register("email")} />
Associate validation messages with the relevant input where appropriate.
Good forms should work with:
- Keyboard navigation
- Screen readers
- Focus management
- Error announcements
Focus Management
After validation fails, users should be able to quickly identify the problem.
For example:
Submit
↓
Validation Error
↓
Focus first invalid field
This becomes especially important in long forms.
Form Performance
For large forms, avoid unnecessary subscriptions.
Instead of watching everything:
watch();
consider watching only what you need:
const accountType = watch("accountType");
This can help keep rendering more targeted.
Enterprise Form Architecture
A scalable form might look like:
features
└── payments
└── forms
└── payment
├── PaymentForm.tsx
├── PaymentHeader.tsx
├── PaymentDetails.tsx
├── BeneficiaryFields.tsx
├── ReviewStep.tsx
│
├── hooks
│ └── usePaymentForm.ts
│
├── schema
│ └── paymentSchema.ts
│
├── services
│ └── paymentService.ts
│
└── types
└── payment.types.ts
Each part has a clear responsibility.
Example Architecture
The overall flow becomes:
User
│
▼
React Form
│
▼
React Hook Form
│
▼
Schema Validation
│
▼
Form Hook
│
▼
Service Layer
│
▼
API Client
│
▼
Backend
The response then flows back:
Backend
↓
API Client
↓
Service
↓
Form Hook
↓
Success / Error
↓
UI
Common Mistakes
1. One Giant Form Component
Avoid:
PaymentForm.tsx
2000 lines
Split the form into meaningful sections.
2. Mixing Validation With UI
Instead of putting hundreds of validation rules directly into JSX, consider a schema.
3. Client-Only Validation
Never trust frontend validation as the final business rule.
4. No Submission Protection
Always consider duplicate submissions.
5. Ignoring Accessibility
A visually beautiful form can still be difficult to use.
6. Overusing Global State
Form state usually doesn't belong in Redux simply because the form is large.
Keep it local to the form workflow unless there is a clear reason to share it.
Senior Engineer Mindset
A junior developer asks:
"How do I create this input?"
A senior developer asks:
"How should this form manage validation and state?"
An architect asks:
"How will this form behave when it has 100 fields, multiple teams, server-side validation, autosave, accessibility requirements, and changing business rules?"
That's the difference between creating a form and designing a form architecture.
Key Takeaways
Today, we learned:
✅ Controlled components give React complete control over input state.
✅ Uncontrolled inputs can reduce unnecessary state updates for large forms.
✅ React Hook Form provides scalable form management.
✅ Schema validation separates validation rules from UI.
✅ Zod can provide both validation and TypeScript type inference.
✅ Dynamic and nested forms require deliberate architecture.
✅ Client-side validation and server-side validation serve different purposes.
✅ Submission state and duplicate-request prevention are critical in production.
✅ Accessibility should be part of form architecture from the beginning.
✅ Large forms should be decomposed into reusable sections and hooks.
Coming Next 🚀
In Day 35, we'll move into another critical enterprise topic:
React Authentication & Authorization Architecture
We'll cover:
- Authentication vs Authorization
- Access tokens
- Refresh tokens
- Protected routes
- Role-Based Access Control
- Permission-Based Access Control
- Route guards
- Token expiration
- Session management
- Secure frontend architecture
- Handling unauthorized API responses
- Logout across multiple browser tabs
This is where we'll connect React, API architecture, routing, state management, and security into one production-ready design.
Happy Coding! 🚀
Top comments (0)