The Evolution of Web Forms — Part 2: From React State to Form Libraries
In Part 1, we built forms using:
- Plain HTML
- Native HTML validation
- Vanilla JavaScript validation
- AJAX submission
Our AJAX registration form could:
- Read input values
- Validate fields
- Display errors
- Submit JSON
- Show a loading state
- Handle backend errors
- Reset after success
That sounds complete.
However, the form had only three fields.
Real production forms may contain dozens of fields, conditional sections, nested objects, uploaded files, dynamic arrays, and multiple validation states.
At that scale, manual DOM manipulation becomes difficult to maintain.
This part covers the next four stages:
- The scaling problems of manual forms
- React controlled forms
- Large React forms and state explosion
- The rise of form libraries
The goal is not merely to learn a different syntax.
The goal is to understand why React improved form development—and why React itself did not completely solve the form problem.
Stage 5: Forms Begin to Scale
Consider a simple login form:
Email
Password
It needs only:
2 values
2 possible field errors
1 loading state
1 general server error
Now consider an employee onboarding form.
Personal information
├── First name
├── Last name
├── Email
├── Phone number
├── Date of birth
└── Profile image
Address
├── Country
├── State
├── City
├── Street
└── Postal code
Employment
├── Job title
├── Department
├── Joining date
├── Employment type
└── Manager
Emergency contacts
├── Contact 1
│ ├── Name
│ ├── Relationship
│ └── Phone
└── Contact 2
├── Name
├── Relationship
└── Phone
Documents
├── Identity proof
├── Address proof
└── Resume
This is no longer “a few inputs.”
It is a small state-management system.
The hidden state inside every field
An input usually has more state than its value.
For an email field, we may need to know:
{
value: "karthik@example.com",
error: "",
touched: true,
dirty: true,
disabled: false,
validating: false
}
These properties answer different questions.
value
What is currently inside the field?
karthik@example.com
error
Is the value invalid?
Email already exists.
touched
Has the user interacted with and left the field?
The user focused the field and then blurred it.
dirty
Is the current value different from its initial value?
Initial value: ""
Current value: "karthik@example.com"
disabled
Should the user be allowed to edit it?
validating
Is an asynchronous validation request running?
For example:
Checking whether this username is available...
A 20-field form can therefore involve far more than 20 pieces of state.
Problem 1: Repeated DOM queries
In Vanilla JavaScript, we might write:
const firstNameInput =
document.getElementById("firstName");
const lastNameInput =
document.getElementById("lastName");
const emailInput =
document.getElementById("email");
const phoneInput =
document.getElementById("phone");
const countryInput =
document.getElementById("country");
const cityInput =
document.getElementById("city");
Then we also need the corresponding error elements:
const firstNameError =
document.getElementById("firstName-error");
const lastNameError =
document.getElementById("lastName-error");
const emailError =
document.getElementById("email-error");
const phoneError =
document.getElementById("phone-error");
For every field, we repeatedly:
- Find the input.
- Find the error element.
- Read the value.
- Validate the value.
- Update accessibility attributes.
- Display or remove the error.
Problem 2: Validation duplication
A large form may contain code like:
if (!firstName) {
showError(
firstNameInput,
firstNameError,
"First name is required."
);
}
if (!lastName) {
showError(
lastNameInput,
lastNameError,
"Last name is required."
);
}
if (!email) {
showError(
emailInput,
emailError,
"Email is required."
);
}
if (!phone) {
showError(
phoneInput,
phoneError,
"Phone number is required."
);
}
The field names change, but the structure remains almost identical.
Repeated code creates several risks:
- A developer forgets to clear one error.
- One field uses a different error convention.
- Accessibility attributes are applied inconsistently.
- A validation rule is updated in one place but not another.
- Backend field errors are mapped differently across forms.
Problem 3: Nested values
Simple forms contain flat values:
{
firstName: "Karthik",
email: "karthik@example.com"
}
Production forms often contain nested data:
{
firstName: "Karthik",
email: "karthik@example.com",
address: {
country: "India",
state: "Andhra Pradesh",
city: "Kadapa"
},
emergencyContact: {
name: "Example Name",
relationship: "Parent",
phone: "9999999999"
}
}
Reading and updating nested values manually is harder.
formData.address.city = cityInput.value;
Error structures also become nested:
{
address: {
city: "City is required."
}
}
The frontend must connect:
errors.address.city
to:
address.city input
Problem 4: Dynamic fields
Suppose a candidate can add multiple work experiences.
Work Experience 1
├── Company
├── Role
├── Start date
└── End date
+ Add another experience
After clicking the button:
Work Experience 1
Work Experience 2
The final data might look like:
{
experiences: [
{
company: "ABC Technologies",
role: "Intern",
startDate: "2025-01-01",
endDate: "2025-06-01"
},
{
company: "XYZ Software",
role: "Developer",
startDate: "2025-07-01",
endDate: "2026-02-01"
}
]
}
Now the form must support:
- Adding fields
- Removing fields
- Reordering fields
- Validating each item
- Preserving stable identifiers
- Mapping nested errors
- Preventing one field's error from appearing under another item
An error might exist at:
experiences[1].company
That means:
The company field
inside the second work-experience item
Manual DOM code becomes increasingly fragile.
Problem 5: Checkboxes
Checkboxes behave differently from text fields.
A single checkbox may represent a Boolean:
<input
type="checkbox"
name="acceptTerms"
/>
Result:
{
acceptTerms: true
}
A checkbox group may represent an array:
Skills:
[x] JavaScript
[x] React
[ ] Angular
Result:
{
skills: ["javascript", "react"]
}
Developers must decide whether to read:
checkbox.checked
or:
checkbox.value
and then construct the final array correctly.
Problem 6: Radio buttons
Radio buttons usually represent one value from a group.
<label>
<input
type="radio"
name="employmentType"
value="full-time"
/>
Full time
</label>
<label>
<input
type="radio"
name="employmentType"
value="part-time"
/>
Part time
</label>
The shared name connects the buttons into one group.
The form value should become:
{
employmentType: "full-time"
}
Validation must treat the group as one logical field, even though it contains multiple HTML inputs.
Problem 7: File uploads
File inputs do not behave exactly like text inputs.
<input
id="resume"
name="resume"
type="file"
/>
The selected value is accessed through:
resumeInput.files
not:
resumeInput.value
A request containing files commonly uses FormData:
const formData = new FormData();
formData.append("firstName", firstName);
formData.append("email", email);
formData.append("resume", resumeInput.files[0]);
File validation may include:
- File type
- File size
- Number of files
- Image dimensions
- Upload progress
- Upload cancellation
- Virus-scanning status
- Server-side storage failure
Problem 8: Conditional fields
Suppose the form asks:
Are you currently employed?
( ) Yes
( ) No
When the user selects Yes, display:
Current company
Current role
Notice period
When the user selects No, those fields disappear.
Now we must answer:
- Should hidden values be preserved?
- Should hidden fields still be validated?
- Should their errors be removed?
- Should they be included in the request?
- What happens if the user selects Yes, enters data, and then selects No?
Conditional forms require explicit state rules.
Problem 9: Cross-field validation
Some rules depend on multiple fields.
Password confirmation
password === confirmPassword
Date range
startDate <= endDate
Conditional requirement
If employmentType is "employed",
currentCompany is required.
At least one selection
At least one preferred communication method
must be selected.
Field-by-field validation is no longer enough.
Problem 10: Asynchronous validation
Suppose the user enters:
Username: karthik
The frontend sends:
GET /api/users/check-username?username=karthik
Possible response:
{
"available": false
}
The form now needs to track:
Idle
↓
Checking username...
↓
Available or unavailable
What if the user types quickly?
k
ka
kar
kart
karthik
Without debouncing or cancellation, the browser may send many requests.
Responses can also arrive out of order.
Request A: "kart"
Request B: "karthik"
Response B arrives first.
Response A arrives later.
The older response must not replace the newer result.
Complete example: A scaling Vanilla JavaScript form
The following example is still manageable, but notice how much infrastructure is required for only ten fields.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0"
/>
<title>Large Vanilla JavaScript Form</title>
<style>
body {
max-width: 700px;
margin: 40px auto;
padding: 0 16px;
font-family: Arial, sans-serif;
}
form {
display: grid;
gap: 18px;
}
.row {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.full-width {
grid-column: 1 / -1;
}
input,
select,
textarea,
button {
padding: 10px;
font: inherit;
}
input[aria-invalid="true"],
select[aria-invalid="true"],
textarea[aria-invalid="true"] {
border: 2px solid #b91c1c;
}
.error {
min-height: 18px;
margin: 0;
color: #b91c1c;
font-size: 14px;
}
.success {
color: #15803d;
}
@media (max-width: 600px) {
.row {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<h1>Employee onboarding</h1>
<form id="employee-form" novalidate>
<div class="row">
<div class="field">
<label for="firstName">First name</label>
<input
id="firstName"
name="firstName"
type="text"
aria-describedby="firstName-error"
/>
<p
id="firstName-error"
class="error"
></p>
</div>
<div class="field">
<label for="lastName">Last name</label>
<input
id="lastName"
name="lastName"
type="text"
aria-describedby="lastName-error"
/>
<p
id="lastName-error"
class="error"
></p>
</div>
</div>
<div class="row">
<div class="field">
<label for="email">Email</label>
<input
id="email"
name="email"
type="email"
aria-describedby="email-error"
/>
<p
id="email-error"
class="error"
></p>
</div>
<div class="field">
<label for="phone">Phone</label>
<input
id="phone"
name="phone"
type="tel"
aria-describedby="phone-error"
/>
<p
id="phone-error"
class="error"
></p>
</div>
</div>
<div class="row">
<div class="field">
<label for="country">Country</label>
<select
id="country"
name="country"
aria-describedby="country-error"
>
<option value="">Select a country</option>
<option value="india">India</option>
<option value="usa">United States</option>
<option value="uk">United Kingdom</option>
</select>
<p
id="country-error"
class="error"
></p>
</div>
<div class="field">
<label for="city">City</label>
<input
id="city"
name="city"
type="text"
aria-describedby="city-error"
/>
<p
id="city-error"
class="error"
></p>
</div>
</div>
<div class="row">
<div class="field">
<label for="jobTitle">Job title</label>
<input
id="jobTitle"
name="jobTitle"
type="text"
aria-describedby="jobTitle-error"
/>
<p
id="jobTitle-error"
class="error"
></p>
</div>
<div class="field">
<label for="joiningDate">
Joining date
</label>
<input
id="joiningDate"
name="joiningDate"
type="date"
aria-describedby="joiningDate-error"
/>
<p
id="joiningDate-error"
class="error"
></p>
</div>
</div>
<div class="field">
<label for="bio">Short biography</label>
<textarea
id="bio"
name="bio"
rows="4"
aria-describedby="bio-error"
></textarea>
<p
id="bio-error"
class="error"
></p>
</div>
<div class="field">
<label>
<input
id="acceptTerms"
name="acceptTerms"
type="checkbox"
aria-describedby="acceptTerms-error"
/>
I accept the employment terms
</label>
<p
id="acceptTerms-error"
class="error"
></p>
</div>
<button id="submit-button" type="submit">
Submit employee details
</button>
<p
id="form-message"
role="status"
aria-live="polite"
></p>
</form>
<script>
const form =
document.getElementById("employee-form");
const submitButton =
document.getElementById("submit-button");
const formMessage =
document.getElementById("form-message");
const fieldNames = [
"firstName",
"lastName",
"email",
"phone",
"country",
"city",
"jobTitle",
"joiningDate",
"bio",
"acceptTerms",
];
const fields = Object.fromEntries(
fieldNames.map((fieldName) => [
fieldName,
{
input: document.getElementById(fieldName),
error: document.getElementById(
`${fieldName}-error`
),
},
])
);
const emailPattern =
/^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const phonePattern =
/^[0-9+\-\s]{8,15}$/;
function getValues() {
return {
firstName:
fields.firstName.input.value.trim(),
lastName:
fields.lastName.input.value.trim(),
email:
fields.email.input.value
.trim()
.toLowerCase(),
phone:
fields.phone.input.value.trim(),
address: {
country:
fields.country.input.value,
city:
fields.city.input.value.trim(),
},
employment: {
jobTitle:
fields.jobTitle.input.value.trim(),
joiningDate:
fields.joiningDate.input.value,
},
bio:
fields.bio.input.value.trim(),
acceptTerms:
fields.acceptTerms.input.checked,
};
}
function validate(values) {
const errors = {};
if (!values.firstName) {
errors.firstName =
"First name is required.";
}
if (!values.lastName) {
errors.lastName =
"Last name is required.";
}
if (!values.email) {
errors.email = "Email is required.";
} else if (
!emailPattern.test(values.email)
) {
errors.email =
"Enter a valid email address.";
}
if (!values.phone) {
errors.phone =
"Phone number is required.";
} else if (
!phonePattern.test(values.phone)
) {
errors.phone =
"Enter a valid phone number.";
}
if (!values.address.country) {
errors.country =
"Country is required.";
}
if (!values.address.city) {
errors.city = "City is required.";
}
if (!values.employment.jobTitle) {
errors.jobTitle =
"Job title is required.";
}
if (!values.employment.joiningDate) {
errors.joiningDate =
"Joining date is required.";
}
if (values.bio.length > 300) {
errors.bio =
"Biography cannot exceed 300 characters.";
}
if (!values.acceptTerms) {
errors.acceptTerms =
"You must accept the terms.";
}
return errors;
}
function showError(fieldName, message) {
const field = fields[fieldName];
if (!field) {
return;
}
field.input.setAttribute(
"aria-invalid",
"true"
);
field.error.textContent = message;
}
function clearError(fieldName) {
const field = fields[fieldName];
if (!field) {
return;
}
field.input.setAttribute(
"aria-invalid",
"false"
);
field.error.textContent = "";
}
function clearErrors() {
fieldNames.forEach(clearError);
}
function renderErrors(errors) {
Object.entries(errors).forEach(
([fieldName, message]) => {
showError(fieldName, message);
}
);
}
function focusFirstError(errors) {
const firstFieldName =
Object.keys(errors)[0];
fields[firstFieldName]?.input.focus();
}
function setSubmitting(isSubmitting) {
submitButton.disabled = isSubmitting;
submitButton.textContent = isSubmitting
? "Submitting..."
: "Submit employee details";
fieldNames.forEach((fieldName) => {
fields[fieldName].input.disabled =
isSubmitting;
});
}
form.addEventListener(
"submit",
async function (event) {
event.preventDefault();
clearErrors();
formMessage.textContent = "";
formMessage.className = "";
const values = getValues();
const errors = validate(values);
if (Object.keys(errors).length > 0) {
renderErrors(errors);
focusFirstError(errors);
return;
}
setSubmitting(true);
try {
await new Promise((resolve) => {
setTimeout(resolve, 1000);
});
console.log(
"Submitted employee:",
values
);
formMessage.textContent =
"Employee details submitted.";
formMessage.className = "success";
form.reset();
} catch (error) {
console.error(error);
formMessage.textContent =
"Unable to submit the form.";
} finally {
setSubmitting(false);
}
}
);
fieldNames.forEach((fieldName) => {
fields[
fieldName
].input.addEventListener(
"input",
function () {
clearError(fieldName);
}
);
fields[
fieldName
].input.addEventListener(
"change",
function () {
clearError(fieldName);
}
);
});
</script>
</body>
</html>
This form works.
But ask yourself:
- Where is the current form state?
- Which fields have been touched?
- Which values are dirty?
- How do other UI components access the values?
- How do we add conditional fields?
- How do we reuse this logic?
- How do we test DOM-manipulation code?
- How do we keep the interface synchronized with the data?
The values live primarily inside the DOM.
JavaScript repeatedly reads the DOM and manually updates it.
DOM stores values
↓
JavaScript reads DOM
↓
JavaScript performs logic
↓
JavaScript manually changes DOM
This is an imperative model.
Imperative UI programming
Imperative code tells the browser exactly what to change.
emailError.textContent =
"Email already exists.";
emailInput.setAttribute(
"aria-invalid",
"true"
);
submitButton.disabled = true;
submitButton.textContent =
"Submitting...";
We give individual instructions:
Change this text.
Disable this button.
Add this attribute.
Focus this input.
Remove this message.
As the number of possible states increases, it becomes harder to guarantee that every DOM element displays the correct state.
A developer might update the error text but forget to update aria-invalid.
Another might enable the button but forget to re-enable the fields.
The desired solution
Developers wanted to describe the interface as a result of state.
Instead of manually saying:
submitButton.disabled = true;
submitButton.textContent = "Submitting...";
we want to express:
<button disabled={isSubmitting}>
{isSubmitting
? "Submitting..."
: "Submit"}
</button>
The idea becomes:
State changes
↓
UI is recalculated from state
↓
Framework updates the required DOM
That declarative approach is one of React's central ideas: rather than manually changing separate DOM elements, developers describe what the UI should look like for the current state.
Advantages of the manual approach
- No framework dependency
- Direct access to browser APIs
- Efficient for small interactions
- Full control over individual DOM updates
- Works in almost every browser environment
- Useful for understanding browser fundamentals
Disadvantages of the manual approach
- Repetitive DOM queries
- State is scattered across DOM elements and variables
- Difficult synchronization
- Imperative updates can become inconsistent
- Nested and dynamic fields are difficult
- Reusability requires custom abstractions
- Testing becomes more complicated
- Large forms produce substantial boilerplate
Common beginner mistake
A common mistake is believing that React was invented specifically to handle forms.
React is a UI library, not a complete form-management system.
React improves how changing UI is represented, but developers must still design:
- Form values
- Errors
- Touched state
- Dirty state
- Submission state
- Validation timing
- Backend error handling
Interview question
What does “declarative UI” mean?
Declarative UI means describing what the interface should look like for the current state instead of manually specifying every DOM operation required to reach that state.
Imperative:
button.disabled = true;
button.textContent = "Saving...";
Declarative:
<button disabled={isSaving}>
{isSaving ? "Saving..." : "Save"}
</button>
Why this evolved
Manual JavaScript forms worked, but their state was distributed across DOM nodes, variables, and event handlers. As applications became more interactive, developers needed a predictable way to derive the interface from application state. React provided that declarative model.
Stage 6: React Controlled Forms
React allows us to build interfaces from components and state.
Instead of asking:
What DOM element should I manually change?
we ask:
What is the current state?
What should the UI look like for that state?
React then updates the required DOM elements.
Controlled components
Consider this React input:
const [email, setEmail] = useState("");
return (
<input
type="email"
value={email}
onChange={(event) => {
setEmail(event.target.value);
}}
/>
);
This is a controlled input.
React calls an input controlled when its displayed value is driven by React state. The official React documentation also notes that controlled inputs update state on every keystroke.
The three important pieces are:
const [email, setEmail] = useState("");
value={email}
onChange={(event) => {
setEmail(event.target.value);
}}
Understanding useState
const [email, setEmail] = useState("");
This creates:
email
→ current state value
setEmail
→ function used to request a state update
""
→ initial value
Initially:
email = ""
After the user types:
k
we call:
setEmail("k");
React runs the component again using the new state.
Understanding value
value={email}
The input does not independently decide its displayed value.
React state provides the value.
React state
↓
input value
If state contains:
karthik@example.com
the input displays:
karthik@example.com
Understanding onChange
onChange={(event) => {
setEmail(event.target.value);
}}
When the user types, the input emits a change event.
React receives the event and reads:
event.target.value
Then state is updated.
User types "k"
↓
onChange fires
↓
event.target.value is "k"
↓
setEmail("k")
↓
React component runs again
↓
value={email} becomes "k"
↓
Input displays "k"
The controlled-input loop
User types
↓
onChange executes
↓
Update React state
↓
Component function runs
↓
React creates next UI description
↓
React updates required DOM
↓
Input shows value
React state becomes the single source of truth.
What does “single source of truth” mean?
Without controlled state, values may exist in several places:
DOM input
JavaScript variable
Validation object
Submission object
Those copies may become inconsistent.
In a controlled form, the current value lives in React state:
const [email, setEmail] = useState("");
The input displays that state:
value={email}
Validation reads that state:
validateEmail(email);
Submission sends that state:
await registerUser({ email });
React state
/ | \
/ | \
Input Validation Submission
Setting up the React example
Create a React TypeScript project using Vite:
npm create vite@latest react-form-demo \
-- --template react-ts
cd react-form-demo
npm install
npm run dev
Replace the contents of src/App.tsx.
Complete controlled registration form
import {
FormEvent,
useState,
} from "react";
interface RegistrationValues {
username: string;
email: string;
password: string;
}
interface RegistrationErrors {
username?: string;
email?: string;
password?: string;
root?: string;
}
const initialValues: RegistrationValues = {
username: "",
email: "",
password: "",
};
function validateRegistration(
values: RegistrationValues
): RegistrationErrors {
const errors: RegistrationErrors = {};
const usernamePattern =
/^[A-Za-z0-9_]+$/;
const emailPattern =
/^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!values.username.trim()) {
errors.username =
"Username is required.";
} else if (
values.username.trim().length < 3
) {
errors.username =
"Username must contain at least 3 characters.";
} else if (
!usernamePattern.test(
values.username.trim()
)
) {
errors.username =
"Use only letters, numbers, and underscores.";
}
if (!values.email.trim()) {
errors.email = "Email is required.";
} else if (
!emailPattern.test(
values.email.trim()
)
) {
errors.email =
"Enter a valid email address.";
}
if (!values.password) {
errors.password =
"Password is required.";
} else if (
values.password.length < 8
) {
errors.password =
"Password must contain at least 8 characters.";
}
return errors;
}
async function registerUser(
values: RegistrationValues
): Promise<void> {
await new Promise((resolve) => {
setTimeout(resolve, 1000);
});
if (
values.email.toLowerCase() ===
"existing@example.com"
) {
throw new Error(
"An account with this email already exists."
);
}
console.log(
"Submitted registration:",
values
);
}
export default function App() {
const [values, setValues] =
useState<RegistrationValues>(
initialValues
);
const [errors, setErrors] =
useState<RegistrationErrors>({});
const [isSubmitting, setIsSubmitting] =
useState(false);
const [isSuccess, setIsSuccess] =
useState(false);
function handleChange(
event: React.ChangeEvent<HTMLInputElement>
) {
const { name, value } = event.target;
setValues((currentValues) => ({
...currentValues,
[name]: value,
}));
setErrors((currentErrors) => ({
...currentErrors,
[name]: undefined,
root: undefined,
}));
setIsSuccess(false);
}
async function handleSubmit(
event: FormEvent<HTMLFormElement>
) {
event.preventDefault();
setIsSuccess(false);
const validationErrors =
validateRegistration(values);
if (
Object.keys(validationErrors).length >
0
) {
setErrors(validationErrors);
return;
}
setErrors({});
setIsSubmitting(true);
try {
await registerUser({
username:
values.username.trim(),
email:
values.email
.trim()
.toLowerCase(),
password:
values.password,
});
setValues(initialValues);
setIsSuccess(true);
} catch (error) {
const message =
error instanceof Error
? error.message
: "Registration failed.";
if (
message.includes(
"email already exists"
)
) {
setErrors({
email: message,
});
} else {
setErrors({
root: message,
});
}
} finally {
setIsSubmitting(false);
}
}
console.log(
"RegistrationForm rendered"
);
return (
<main className="page">
<form
className="form"
onSubmit={handleSubmit}
noValidate
>
<h1>Create an account</h1>
<div className="field">
<label htmlFor="username">
Username
</label>
<input
id="username"
name="username"
type="text"
value={values.username}
onChange={handleChange}
disabled={isSubmitting}
aria-invalid={Boolean(
errors.username
)}
aria-describedby={
errors.username
? "username-error"
: undefined
}
/>
{errors.username && (
<p
id="username-error"
className="error"
>
{errors.username}
</p>
)}
</div>
<div className="field">
<label htmlFor="email">
Email
</label>
<input
id="email"
name="email"
type="email"
value={values.email}
onChange={handleChange}
disabled={isSubmitting}
aria-invalid={Boolean(
errors.email
)}
aria-describedby={
errors.email
? "email-error"
: undefined
}
/>
{errors.email && (
<p
id="email-error"
className="error"
>
{errors.email}
</p>
)}
</div>
<div className="field">
<label htmlFor="password">
Password
</label>
<input
id="password"
name="password"
type="password"
value={values.password}
onChange={handleChange}
disabled={isSubmitting}
aria-invalid={Boolean(
errors.password
)}
aria-describedby={
errors.password
? "password-error"
: undefined
}
/>
{errors.password && (
<p
id="password-error"
className="error"
>
{errors.password}
</p>
)}
</div>
{errors.root && (
<p
className="error"
role="alert"
>
{errors.root}
</p>
)}
{isSuccess && (
<p
className="success"
role="status"
>
Registration successful.
</p>
)}
<button
type="submit"
disabled={isSubmitting}
>
{isSubmitting
? "Creating account..."
: "Register"}
</button>
</form>
</main>
);
}
Add this to src/index.css:
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family:
Inter,
Arial,
sans-serif;
background: #f5f5f5;
}
button,
input {
font: inherit;
}
.page {
min-height: 100vh;
display: grid;
place-items: center;
padding: 24px;
}
.form {
width: min(100%, 480px);
display: grid;
gap: 18px;
padding: 28px;
background: white;
border-radius: 12px;
}
.field {
display: grid;
gap: 6px;
}
input {
width: 100%;
padding: 10px 12px;
border: 1px solid #777;
border-radius: 6px;
}
input[aria-invalid="true"] {
border-color: #b91c1c;
}
button {
padding: 11px 16px;
border: 0;
border-radius: 6px;
cursor: pointer;
}
button:disabled {
cursor: not-allowed;
opacity: 0.65;
}
.error {
margin: 0;
color: #b91c1c;
font-size: 14px;
}
.success {
margin: 0;
color: #15803d;
}
Understanding the form state
The component has four main states.
const [values, setValues] =
useState(initialValues);
const [errors, setErrors] =
useState({});
const [isSubmitting, setIsSubmitting] =
useState(false);
const [isSuccess, setIsSuccess] =
useState(false);
They represent different concerns.
values
→ What the user entered
errors
→ What is invalid
isSubmitting
→ Whether the request is running
isSuccess
→ Whether the request succeeded
Why use one values object?
We could write:
const [username, setUsername] =
useState("");
const [email, setEmail] =
useState("");
const [password, setPassword] =
useState("");
That works for small forms.
Instead, we use:
const [values, setValues] = useState({
username: "",
email: "",
password: "",
});
This makes it easier to:
- Pass all values to validation
- Submit the complete object
- Reset all fields
- Create one shared change handler
Understanding the shared handleChange
function handleChange(
event: React.ChangeEvent<HTMLInputElement>
) {
const { name, value } = event.target;
setValues((currentValues) => ({
...currentValues,
[name]: value,
}));
}
Suppose the email input changes.
<input
name="email"
onChange={handleChange}
/>
The event contains:
name = "email"
value = "karthik@example.com"
This:
[name]: value
becomes:
email: "karthik@example.com"
The previous state might be:
{
username: "karthik",
email: "",
password: "Password123"
}
After the update:
{
username: "karthik",
email: "karthik@example.com",
password: "Password123"
}
The spread operator preserves the other fields.
{
...currentValues,
[name]: value
}
Without the spread:
setValues({
[name]: value,
});
the other values would be removed.
Why must the input have a name?
<input
name="email"
value={values.email}
onChange={handleChange}
/>
The handler uses:
event.target.name
to decide which property to update.
The names must match the object keys.
input name="email"
↓
values.email
input name="username"
↓
values.username
Every keystroke render flow
Suppose the component currently contains:
email = "kar"
The user types:
t
The flow is:
1. Browser detects input change
2. React calls handleChange()
3. event.target.value is "kart"
4. setValues() requests state update
5. React runs App() again
6. values.email is now "kart"
7. JSX is recalculated
8. React compares previous and next output
9. React updates the input's DOM value
The component function runs again, but React does not necessarily rebuild every real DOM node.
It calculates the next UI description and updates what changed.
Observing renders
The example contains:
console.log(
"RegistrationForm rendered"
);
Open the browser console and type:
karthik@example.com
You will see the message repeatedly.
That is expected for controlled inputs because each keystroke updates state.
React's current documentation explicitly warns that controlled inputs set state on every keystroke and can become slow when that state causes a large surrounding tree to re-render. It recommends isolating form state or using deferred updates where appropriate.
For a three-field form, this is normally not a problem.
For a large form embedded inside an expensive page, it can matter.
Controlled form render diagram
┌──────────────────┐
│ React component │
│ state │
└────────┬─────────┘
│
│ value={values.email}
▼
┌──────────────────┐
│ Email input │
└────────┬─────────┘
│
│ User types
▼
┌──────────────────┐
│ onChange │
└────────┬─────────┘
│
│ setValues(...)
▼
┌──────────────────┐
│ Component runs │
│ again │
└────────┬─────────┘
│
└─────── loop
Why React is better than manual DOM manipulation
In Vanilla JavaScript:
if (error) {
errorElement.textContent = error;
input.setAttribute(
"aria-invalid",
"true"
);
} else {
errorElement.textContent = "";
input.setAttribute(
"aria-invalid",
"false"
);
}
In React:
<input
aria-invalid={Boolean(error)}
/>
{error && (
<p className="error">
{error}
</p>
)}
We describe the UI for the current state.
If error exists
→ render error paragraph
→ set aria-invalid to true
If error does not exist
→ do not render paragraph
→ set aria-invalid to false
The UI follows the data.
Advantages of controlled React forms
- React state is the source of truth
- UI is declarative
- Values are immediately available
- Conditional fields are easier to express
- Inputs can depend on other values
- Validation can use current state
- Resetting can be done through state
- Components can receive values through props
- Testing state-driven UI is generally more predictable
- TypeScript can describe the form structure
Disadvantages of controlled React forms
- Every keystroke updates state
- Component functions run again after updates
- Considerable boilerplate
- Manual error state is still required
- Manual touched and dirty tracking may still be required
- Large forms can become difficult to optimize
- Custom components need careful value and event wiring
- Developers can accidentally create controlled/uncontrolled warnings
Common beginner mistake: missing onChange
<input value={email} />
The input becomes effectively read-only because React always forces its value to equal email, but nothing updates email.
Correct:
<input
value={email}
onChange={(event) => {
setEmail(event.target.value);
}}
/>
Common beginner mistake: using undefined
An input may start uncontrolled:
const [email, setEmail] =
useState<string | undefined>(
undefined
);
Later it becomes controlled:
setEmail("karthik@example.com");
React may warn that an uncontrolled input became controlled.
For text inputs, use a consistent string initial value:
const [email, setEmail] =
useState("");
Common beginner mistake: mutating state
Bad:
values.email =
event.target.value;
setValues(values);
This mutates the existing object.
Better:
setValues((currentValues) => ({
...currentValues,
email: event.target.value,
}));
React state should be treated as immutable.
Common beginner mistake: one handler for incompatible input types
This works for text inputs:
const { name, value } =
event.target;
But a checkbox needs:
event.target.checked
A file input needs:
event.target.files
A robust handler must understand the input type.
function handleChange(
event: React.ChangeEvent<HTMLInputElement>
) {
const {
name,
type,
value,
checked,
files,
} = event.target;
let nextValue:
| string
| boolean
| FileList
| null;
if (type === "checkbox") {
nextValue = checked;
} else if (type === "file") {
nextValue = files;
} else {
nextValue = value;
}
setValues((currentValues) => ({
...currentValues,
[name]: nextValue,
}));
}
Even this becomes more complicated when checkbox groups represent arrays.
TypeScript best practice
Define an explicit type for the form values.
interface RegistrationValues {
username: string;
email: string;
password: string;
}
Then type the initial object:
const initialValues:
RegistrationValues = {
username: "",
email: "",
password: "",
};
This protects against mistakes such as:
setValues({
username: "",
email: "",
// password accidentally missing
});
Interview question
What is a controlled component?
A controlled form component is an input whose current value is provided by React state and whose changes update that state through an event handler.
const [email, setEmail] =
useState("");
<input
value={email}
onChange={(event) => {
setEmail(event.target.value);
}}
/>
Why this evolved
Controlled React forms replaced scattered DOM manipulation with predictable state-driven UI. However, React only provided the building blocks. Developers still had to manually manage values, errors, touched fields, dirty fields, validation, submission, and performance. As forms grew, this created a new kind of complexity.
Stage 7: Large React Forms and State Explosion
A three-field controlled form is clean enough.
A ten-field form begins to reveal the cost.
Imagine tracking:
10 values
10 field errors
10 touched states
10 dirty states
1 loading state
1 success state
1 root error
Depending on the design, that can produce more than 40 logical state values.
Approach 1: Separate state for every field
A beginner may begin like this:
const [firstName, setFirstName] =
useState("");
const [lastName, setLastName] =
useState("");
const [email, setEmail] =
useState("");
const [phone, setPhone] =
useState("");
const [country, setCountry] =
useState("");
const [city, setCity] =
useState("");
const [jobTitle, setJobTitle] =
useState("");
const [joiningDate, setJoiningDate] =
useState("");
const [bio, setBio] =
useState("");
const [acceptTerms, setAcceptTerms] =
useState(false);
Then errors:
const [firstNameError, setFirstNameError] =
useState("");
const [lastNameError, setLastNameError] =
useState("");
const [emailError, setEmailError] =
useState("");
const [phoneError, setPhoneError] =
useState("");
Then touched state:
const [emailTouched, setEmailTouched] =
useState(false);
const [phoneTouched, setPhoneTouched] =
useState(false);
This quickly becomes unmanageable.
Approach 2: Group related state
A better approach uses objects:
const [values, setValues] =
useState(initialValues);
const [errors, setErrors] =
useState({});
const [touched, setTouched] =
useState({});
const [dirtyFields, setDirtyFields] =
useState({});
This reduces the number of useState calls.
However, we must still write the logic that updates every object correctly.
Understanding touched
A field is usually considered touched after the user focuses and leaves it.
function handleBlur(
event: React.FocusEvent<
HTMLInputElement
>
) {
const { name } = event.target;
setTouched((currentTouched) => ({
...currentTouched,
[name]: true,
}));
}
Then we might show an error only when the field is touched:
{touched.email &&
errors.email && (
<p>{errors.email}</p>
)}
This avoids displaying errors before the user has interacted.
Understanding dirty
A field is dirty when its current value differs from its initial value.
Initial email:
""
Current email:
"karthik@example.com"
Dirty:
true
A simple update:
setDirtyFields(
(currentDirtyFields) => ({
...currentDirtyFields,
[name]:
value !==
initialValues[
name as keyof FormValues
],
})
);
The entire form is dirty if at least one field is dirty.
const isDirty =
Object.values(
dirtyFields
).some(Boolean);
Why touched and dirty are different
Suppose the user:
- Clicks the email input.
- Types nothing.
- Clicks outside.
Result:
touched = true
dirty = false
Now suppose the user:
- Types an email.
- Deletes it back to the original empty value.
Depending on the form system's definition:
touched = true
dirty = false
The field was modified, but its current value equals its default value.
Touched answers:
Has the user interacted with the field?
Dirty answers:
Does the current value differ from the default?
Complete large controlled form
The example below manages:
- Ten values
- Field errors
- Touched fields
- Dirty fields
- Loading state
- Success state
- Disabled state
- Validation on blur
- Validation on submit
- Form reset
import {
ChangeEvent,
FocusEvent,
FormEvent,
useState,
} from "react";
interface EmployeeFormValues {
firstName: string;
lastName: string;
email: string;
phone: string;
country: string;
city: string;
jobTitle: string;
joiningDate: string;
bio: string;
acceptTerms: boolean;
}
type EmployeeFieldName =
keyof EmployeeFormValues;
type EmployeeErrors =
Partial<
Record<EmployeeFieldName, string>
> & {
root?: string;
};
type EmployeeTouched =
Partial<
Record<EmployeeFieldName, boolean>
>;
type EmployeeDirtyFields =
Partial<
Record<EmployeeFieldName, boolean>
>;
const initialValues:
EmployeeFormValues = {
firstName: "",
lastName: "",
email: "",
phone: "",
country: "",
city: "",
jobTitle: "",
joiningDate: "",
bio: "",
acceptTerms: false,
};
const emailPattern =
/^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const phonePattern =
/^[0-9+\-\s]{8,15}$/;
function validateField(
name: EmployeeFieldName,
value:
EmployeeFormValues[EmployeeFieldName]
): string | undefined {
switch (name) {
case "firstName": {
if (
typeof value !== "string" ||
!value.trim()
) {
return "First name is required.";
}
if (value.trim().length < 2) {
return "First name is too short.";
}
return undefined;
}
case "lastName": {
if (
typeof value !== "string" ||
!value.trim()
) {
return "Last name is required.";
}
return undefined;
}
case "email": {
if (
typeof value !== "string" ||
!value.trim()
) {
return "Email is required.";
}
if (
!emailPattern.test(value.trim())
) {
return "Enter a valid email address.";
}
return undefined;
}
case "phone": {
if (
typeof value !== "string" ||
!value.trim()
) {
return "Phone number is required.";
}
if (
!phonePattern.test(value.trim())
) {
return "Enter a valid phone number.";
}
return undefined;
}
case "country": {
if (!value) {
return "Country is required.";
}
return undefined;
}
case "city": {
if (
typeof value !== "string" ||
!value.trim()
) {
return "City is required.";
}
return undefined;
}
case "jobTitle": {
if (
typeof value !== "string" ||
!value.trim()
) {
return "Job title is required.";
}
return undefined;
}
case "joiningDate": {
if (!value) {
return "Joining date is required.";
}
return undefined;
}
case "bio": {
if (
typeof value === "string" &&
value.trim().length > 300
) {
return "Biography cannot exceed 300 characters.";
}
return undefined;
}
case "acceptTerms": {
if (value !== true) {
return "You must accept the terms.";
}
return undefined;
}
default: {
return undefined;
}
}
}
function validateForm(
values: EmployeeFormValues
): EmployeeErrors {
const errors: EmployeeErrors = {};
(
Object.keys(
values
) as EmployeeFieldName[]
).forEach((name) => {
const error = validateField(
name,
values[name]
);
if (error) {
errors[name] = error;
}
});
return errors;
}
async function submitEmployee(
values: EmployeeFormValues
): Promise<void> {
await new Promise((resolve) => {
setTimeout(resolve, 1200);
});
console.log(
"Submitted employee:",
values
);
}
export default function App() {
const [values, setValues] =
useState<EmployeeFormValues>(
initialValues
);
const [errors, setErrors] =
useState<EmployeeErrors>({});
const [touched, setTouched] =
useState<EmployeeTouched>({});
const [
dirtyFields,
setDirtyFields,
] =
useState<EmployeeDirtyFields>(
{}
);
const [isSubmitting, setIsSubmitting] =
useState(false);
const [isSuccess, setIsSuccess] =
useState(false);
const isDirty =
Object.values(
dirtyFields
).some(Boolean);
function updateField<
TName extends EmployeeFieldName,
>(
name: TName,
value: EmployeeFormValues[TName]
) {
setValues((currentValues) => ({
...currentValues,
[name]: value,
}));
setDirtyFields(
(currentDirtyFields) => ({
...currentDirtyFields,
[name]:
value !== initialValues[name],
})
);
setErrors((currentErrors) => ({
...currentErrors,
[name]: undefined,
root: undefined,
}));
setIsSuccess(false);
}
function handleTextChange(
event: ChangeEvent<
HTMLInputElement |
HTMLTextAreaElement |
HTMLSelectElement
>
) {
const name =
event.target
.name as EmployeeFieldName;
updateField(
name,
event.target.value
);
}
function handleCheckboxChange(
event: ChangeEvent<HTMLInputElement>
) {
updateField(
"acceptTerms",
event.target.checked
);
}
function handleBlur(
event: FocusEvent<
HTMLInputElement |
HTMLTextAreaElement |
HTMLSelectElement
>
) {
const name =
event.target
.name as EmployeeFieldName;
setTouched(
(currentTouched) => ({
...currentTouched,
[name]: true,
})
);
const fieldError =
validateField(
name,
values[name]
);
setErrors(
(currentErrors) => ({
...currentErrors,
[name]: fieldError,
})
);
}
function resetForm() {
setValues(initialValues);
setErrors({});
setTouched({});
setDirtyFields({});
setIsSuccess(false);
}
async function handleSubmit(
event: FormEvent<HTMLFormElement>
) {
event.preventDefault();
setIsSuccess(false);
const nextErrors =
validateForm(values);
const allTouched =
Object.fromEntries(
Object.keys(values).map(
(name) => [
name,
true,
]
)
) as EmployeeTouched;
setTouched(allTouched);
setErrors(nextErrors);
if (
Object.keys(nextErrors).length >
0
) {
return;
}
setIsSubmitting(true);
try {
await submitEmployee({
...values,
firstName:
values.firstName.trim(),
lastName:
values.lastName.trim(),
email:
values.email
.trim()
.toLowerCase(),
phone:
values.phone.trim(),
city:
values.city.trim(),
jobTitle:
values.jobTitle.trim(),
bio:
values.bio.trim(),
});
resetForm();
setIsSuccess(true);
} catch (error) {
setErrors({
root:
error instanceof Error
? error.message
: "Submission failed.",
});
} finally {
setIsSubmitting(false);
}
}
function getError(
name: EmployeeFieldName
) {
if (!touched[name]) {
return undefined;
}
return errors[name];
}
const firstNameError =
getError("firstName");
const lastNameError =
getError("lastName");
const emailError =
getError("email");
const phoneError =
getError("phone");
const countryError =
getError("country");
const cityError =
getError("city");
const jobTitleError =
getError("jobTitle");
const joiningDateError =
getError("joiningDate");
const bioError =
getError("bio");
const acceptTermsError =
getError("acceptTerms");
return (
<main className="page">
<form
className="form"
onSubmit={handleSubmit}
noValidate
>
<header>
<h1>Employee onboarding</h1>
<p>
Form status:{" "}
{isDirty
? "Unsaved changes"
: "No changes"}
</p>
</header>
<div className="row">
<div className="field">
<label htmlFor="firstName">
First name
</label>
<input
id="firstName"
name="firstName"
value={values.firstName}
onChange={handleTextChange}
onBlur={handleBlur}
disabled={isSubmitting}
aria-invalid={Boolean(
firstNameError
)}
aria-describedby={
firstNameError
? "firstName-error"
: undefined
}
/>
{firstNameError && (
<p
id="firstName-error"
className="error"
>
{firstNameError}
</p>
)}
</div>
<div className="field">
<label htmlFor="lastName">
Last name
</label>
<input
id="lastName"
name="lastName"
value={values.lastName}
onChange={handleTextChange}
onBlur={handleBlur}
disabled={isSubmitting}
aria-invalid={Boolean(
lastNameError
)}
aria-describedby={
lastNameError
? "lastName-error"
: undefined
}
/>
{lastNameError && (
<p
id="lastName-error"
className="error"
>
{lastNameError}
</p>
)}
</div>
</div>
<div className="row">
<div className="field">
<label htmlFor="email">
Email
</label>
<input
id="email"
name="email"
type="email"
value={values.email}
onChange={handleTextChange}
onBlur={handleBlur}
disabled={isSubmitting}
aria-invalid={Boolean(
emailError
)}
aria-describedby={
emailError
? "email-error"
: undefined
}
/>
{emailError && (
<p
id="email-error"
className="error"
>
{emailError}
</p>
)}
</div>
<div className="field">
<label htmlFor="phone">
Phone
</label>
<input
id="phone"
name="phone"
type="tel"
value={values.phone}
onChange={handleTextChange}
onBlur={handleBlur}
disabled={isSubmitting}
aria-invalid={Boolean(
phoneError
)}
aria-describedby={
phoneError
? "phone-error"
: undefined
}
/>
{phoneError && (
<p
id="phone-error"
className="error"
>
{phoneError}
</p>
)}
</div>
</div>
<div className="row">
<div className="field">
<label htmlFor="country">
Country
</label>
<select
id="country"
name="country"
value={values.country}
onChange={handleTextChange}
onBlur={handleBlur}
disabled={isSubmitting}
aria-invalid={Boolean(
countryError
)}
aria-describedby={
countryError
? "country-error"
: undefined
}
>
<option value="">
Select a country
</option>
<option value="india">
India
</option>
<option value="usa">
United States
</option>
<option value="uk">
United Kingdom
</option>
</select>
{countryError && (
<p
id="country-error"
className="error"
>
{countryError}
</p>
)}
</div>
<div className="field">
<label htmlFor="city">
City
</label>
<input
id="city"
name="city"
value={values.city}
onChange={handleTextChange}
onBlur={handleBlur}
disabled={isSubmitting}
aria-invalid={Boolean(
cityError
)}
aria-describedby={
cityError
? "city-error"
: undefined
}
/>
{cityError && (
<p
id="city-error"
className="error"
>
{cityError}
</p>
)}
</div>
</div>
<div className="row">
<div className="field">
<label htmlFor="jobTitle">
Job title
</label>
<input
id="jobTitle"
name="jobTitle"
value={values.jobTitle}
onChange={handleTextChange}
onBlur={handleBlur}
disabled={isSubmitting}
aria-invalid={Boolean(
jobTitleError
)}
aria-describedby={
jobTitleError
? "jobTitle-error"
: undefined
}
/>
{jobTitleError && (
<p
id="jobTitle-error"
className="error"
>
{jobTitleError}
</p>
)}
</div>
<div className="field">
<label htmlFor="joiningDate">
Joining date
</label>
<input
id="joiningDate"
name="joiningDate"
type="date"
value={values.joiningDate}
onChange={handleTextChange}
onBlur={handleBlur}
disabled={isSubmitting}
aria-invalid={Boolean(
joiningDateError
)}
aria-describedby={
joiningDateError
? "joiningDate-error"
: undefined
}
/>
{joiningDateError && (
<p
id="joiningDate-error"
className="error"
>
{joiningDateError}
</p>
)}
</div>
</div>
<div className="field">
<label htmlFor="bio">
Short biography
</label>
<textarea
id="bio"
name="bio"
rows={4}
value={values.bio}
onChange={handleTextChange}
onBlur={handleBlur}
disabled={isSubmitting}
aria-invalid={Boolean(
bioError
)}
aria-describedby={
bioError
? "bio-error"
: "bio-hint"
}
/>
<p id="bio-hint">
{values.bio.length}/300
characters
</p>
{bioError && (
<p
id="bio-error"
className="error"
>
{bioError}
</p>
)}
</div>
<div className="field">
<label>
<input
name="acceptTerms"
type="checkbox"
checked={
values.acceptTerms
}
onChange={
handleCheckboxChange
}
onBlur={handleBlur}
disabled={isSubmitting}
aria-invalid={Boolean(
acceptTermsError
)}
aria-describedby={
acceptTermsError
? "acceptTerms-error"
: undefined
}
/>
I accept the employment terms
</label>
{acceptTermsError && (
<p
id="acceptTerms-error"
className="error"
>
{acceptTermsError}
</p>
)}
</div>
{errors.root && (
<p
className="error"
role="alert"
>
{errors.root}
</p>
)}
{isSuccess && (
<p
className="success"
role="status"
>
Employee details submitted.
</p>
)}
<div className="actions">
<button
type="button"
onClick={resetForm}
disabled={
isSubmitting ||
!isDirty
}
>
Reset
</button>
<button
type="submit"
disabled={isSubmitting}
>
{isSubmitting
? "Submitting..."
: "Submit"}
</button>
</div>
</form>
</main>
);
}
Add these styles to the earlier CSS:
.form {
width: min(100%, 760px);
}
.row {
display: grid;
grid-template-columns:
repeat(2, minmax(0, 1fr));
gap: 16px;
}
textarea,
select {
width: 100%;
padding: 10px 12px;
font: inherit;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 12px;
}
@media (max-width: 640px) {
.row {
grid-template-columns: 1fr;
}
}
What did we have to build manually?
Although the form is written in React, we manually implemented:
Values
Errors
Touched fields
Dirty fields
Overall dirty status
Validation on blur
Validation on submit
Error visibility rules
Reset behavior
Loading state
Success state
Checkbox handling
Input normalization
Root-level errors
React handled rendering.
React did not design the form architecture for us.
Approximate boilerplate
Depending on formatting and component extraction, a ten-field controlled form can easily require hundreds of lines.
The problem is not merely the number of lines.
The deeper problem is that much of the code is infrastructure rather than business-specific UI.
For example:
setTouched((currentTouched) => ({
...currentTouched,
[name]: true,
}));
setDirtyFields(
(currentDirtyFields) => ({
...currentDirtyFields,
[name]:
value !== initialValues[name],
})
);
setErrors((currentErrors) => ({
...currentErrors,
[name]: undefined,
}));
Every React project began recreating similar logic.
State explosion diagram
Form
├── values
│ ├── firstName
│ ├── lastName
│ ├── email
│ ├── phone
│ └── ...
│
├── errors
│ ├── firstName
│ ├── lastName
│ ├── email
│ └── ...
│
├── touched
│ ├── firstName
│ ├── lastName
│ └── ...
│
├── dirtyFields
│ ├── firstName
│ ├── lastName
│ └── ...
│
├── isDirty
├── isValid
├── isSubmitting
├── isSuccess
└── rootError
Performance problems
Every controlled input change updates React state.
User types
↓
setValues()
↓
Parent form component runs again
↓
All JSX inside the component is recalculated
React is efficient, and rerendering does not mean every DOM element is recreated.
However, a large component may still perform:
- Validation calculations
- Derived-state calculations
- Rendering of many child components
- Expensive formatting
- Filtering or searching
- Conditional layout work
React's documentation recommends moving controlled input state into a smaller component when a large surrounding tree does not need to update on every keystroke.
Common performance mistake
Consider:
export default function CheckoutPage() {
const [email, setEmail] =
useState("");
return (
<>
<LargeNavigation />
<ProductList />
<Recommendations />
<CheckoutSummary />
<input
value={email}
onChange={(event) => {
setEmail(
event.target.value
);
}}
/>
</>
);
}
Every email update causes CheckoutPage to run again.
A better structure may isolate the form:
export default function CheckoutPage() {
return (
<>
<LargeNavigation />
<ProductList />
<Recommendations />
<CheckoutSummary />
<CheckoutForm />
</>
);
}
Now the form state lives inside:
function CheckoutForm() {
const [email, setEmail] =
useState("");
return (
<input
value={email}
onChange={(event) => {
setEmail(
event.target.value
);
}}
/>
);
}
Only the relevant subtree needs to rerender.
Derived state can become inconsistent
Suppose we store both:
const [values, setValues] =
useState(initialValues);
const [isDirty, setIsDirty] =
useState(false);
Now two pieces of state describe related information.
A bug may occur:
setValues(initialValues);
// Forgot:
setIsDirty(false);
The form is empty but still says:
Unsaved changes
Whenever possible, derive state:
const isDirty =
Object.keys(values).some((key) => {
const name =
key as keyof EmployeeFormValues;
return (
values[name] !==
initialValues[name]
);
});
However, repeated derivation can also become expensive or complicated for nested values.
Form libraries attempt to manage these relationships consistently.
Advantages of a manual large React form
- Complete control
- No form-library dependency
- Business logic is explicit
- Easy to customize unusual behavior
- Useful for learning React state deeply
- Appropriate for small or highly specialized forms
Disadvantages of a manual large React form
- Significant boilerplate
- Manual field registration
- Manual touched-state tracking
- Manual dirty-state tracking
- Manual validation timing
- Manual nested updates
- More opportunities for inconsistent state
- Performance optimization becomes the application's responsibility
- Dynamic arrays require considerable custom code
- Repeating the architecture across many forms wastes time
Senior engineer tip
Do not install a form library merely because a form exists.
A small search form may need only:
const [query, setQuery] =
useState("");
A library becomes valuable when it removes meaningful complexity.
Good candidates include forms with:
- Many fields
- Validation schemas
- Dynamic arrays
- Nested values
- Conditional sections
- Server errors
- Reusable input components
- Complex submission states
- Performance-sensitive pages
Interview question
What is the difference between touched and dirty?
Touched indicates that the user interacted with a field, commonly by focusing and then leaving it.
Dirty indicates that the current value differs from the field's initial or default value.
A field can be touched without being dirty.
Why this evolved
React made form UI predictable, but developers repeatedly rebuilt the same state-management infrastructure: values, errors, touched fields, dirty fields, validation, resets, and submission handling. Form libraries emerged to standardize and reuse that work.
Stage 8: The Rise of Form Libraries
A form library is not mainly an input-design library.
It generally does not decide:
- Your colors
- Your border radius
- Your labels
- Your layout
- Your design system
A form library manages form behavior and state.
It commonly provides:
Value management
Error management
Touched tracking
Dirty tracking
Validation
Submission handling
Field registration
Reset behavior
Nested values
Dynamic arrays
Server-error APIs
Performance optimizations
What form libraries abstract
Without a library:
const [values, setValues] =
useState(initialValues);
const [errors, setErrors] =
useState({});
const [touched, setTouched] =
useState({});
const [dirtyFields, setDirtyFields] =
useState({});
const [isSubmitting, setIsSubmitting] =
useState(false);
With a library, the API may resemble:
const {
register,
handleSubmit,
formState: {
errors,
isDirty,
dirtyFields,
touchedFields,
isSubmitting,
},
} = useForm();
The complexity has not disappeared.
The library now manages it.
Important distinction: abstraction versus magic
A form library does not make validation, state, or events disappear.
It provides reusable implementations.
Your form
↓
Form-library API
↓
Internal form state
↓
Field subscriptions or React updates
↓
Rendered interface
Understanding the manual approach helps you debug the abstraction.
Major React form-library families
This section compares:
- Formik
- Final Form
- React Final Form
- React Hook Form
- TanStack Form
They solve similar problems but make different architectural choices.
Formik
Formik became one of the most recognizable React form libraries during the late 2010s. Its repository and community resources show active production use and tutorials from 2017 onward.
Formik describes its purpose as handling repetitive form concerns such as values, errors, visited fields, validation, and submission.
Its mental model feels familiar to developers who already understand controlled React state.
Formik's basic approach
Formik stores form state
↓
Formik gives values and handlers
↓
Inputs receive value/onChange/onBlur
↓
State updates
↓
Relevant React UI rerenders
Common Formik state includes:
values
errors
touched
dirty
isSubmitting
Install Formik
npm install formik
Complete Formik example
import {
Form,
Formik,
} from "formik";
interface RegistrationValues {
username: string;
email: string;
password: string;
}
interface RegistrationErrors {
username?: string;
email?: string;
password?: string;
}
const initialValues:
RegistrationValues = {
username: "",
email: "",
password: "",
};
function validate(
values: RegistrationValues
): RegistrationErrors {
const errors:
RegistrationErrors = {};
if (!values.username.trim()) {
errors.username =
"Username is required.";
} else if (
values.username.trim().length < 3
) {
errors.username =
"Username must contain at least 3 characters.";
}
if (!values.email.trim()) {
errors.email =
"Email is required.";
} else if (
!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(
values.email.trim()
)
) {
errors.email =
"Enter a valid email address.";
}
if (!values.password) {
errors.password =
"Password is required.";
} else if (
values.password.length < 8
) {
errors.password =
"Password must contain at least 8 characters.";
}
return errors;
}
export default function FormikExample() {
return (
<Formik<RegistrationValues>
initialValues={initialValues}
validate={validate}
onSubmit={async (
values,
helpers
) => {
try {
await new Promise(
(resolve) => {
setTimeout(
resolve,
1000
);
}
);
if (
values.email ===
"existing@example.com"
) {
helpers.setFieldError(
"email",
"Email already exists."
);
return;
}
console.log(values);
helpers.resetForm();
} finally {
helpers.setSubmitting(
false
);
}
}}
>
{({
values,
errors,
touched,
dirty,
isSubmitting,
handleChange,
handleBlur,
}) => (
<Form
className="form"
noValidate
>
<h1>Formik registration</h1>
<p>
{dirty
? "Unsaved changes"
: "No changes"}
</p>
<div className="field">
<label htmlFor="username">
Username
</label>
<input
id="username"
name="username"
value={
values.username
}
onChange={
handleChange
}
onBlur={handleBlur}
aria-invalid={Boolean(
touched.username &&
errors.username
)}
aria-describedby={
touched.username &&
errors.username
? "username-error"
: undefined
}
/>
{touched.username &&
errors.username && (
<p
id="username-error"
className="error"
>
{errors.username}
</p>
)}
</div>
<div className="field">
<label htmlFor="email">
Email
</label>
<input
id="email"
name="email"
type="email"
value={values.email}
onChange={
handleChange
}
onBlur={handleBlur}
aria-invalid={Boolean(
touched.email &&
errors.email
)}
aria-describedby={
touched.email &&
errors.email
? "email-error"
: undefined
}
/>
{touched.email &&
errors.email && (
<p
id="email-error"
className="error"
>
{errors.email}
</p>
)}
</div>
<div className="field">
<label htmlFor="password">
Password
</label>
<input
id="password"
name="password"
type="password"
value={
values.password
}
onChange={
handleChange
}
onBlur={handleBlur}
aria-invalid={Boolean(
touched.password &&
errors.password
)}
aria-describedby={
touched.password &&
errors.password
? "password-error"
: undefined
}
/>
{touched.password &&
errors.password && (
<p
id="password-error"
className="error"
>
{errors.password}
</p>
)}
</div>
<button
type="submit"
disabled={isSubmitting}
>
{isSubmitting
? "Registering..."
: "Register"}
</button>
</Form>
)}
</Formik>
);
}
What Formik removed
We no longer manually created:
const [values, setValues] =
useState(...);
const [errors, setErrors] =
useState(...);
const [touched, setTouched] =
useState(...);
const [isSubmitting, setIsSubmitting] =
useState(...);
Formik provided:
values
errors
touched
dirty
isSubmitting
handleChange
handleBlur
setFieldError
resetForm
Formik advantages
- Familiar controlled-form mental model
- Mature API
- Strong historical ecosystem
- Handles values, errors, touched, and submission
- Integrates with schema validators
- Supports nested values and arrays
- Straightforward for developers familiar with React state
Formik disadvantages
- Can produce broad rerenders in large forms
- Render-prop code may become deeply nested
- Controlled-state architecture can require performance work
- Large forms may need field-level optimization
- Some modern teams prefer hook-based or subscription-based alternatives
Formik does provide APIs such as hooks and optimized fields, but developers must still understand how form-state changes affect rendering.
Final Form
Final Form is a framework-independent form-state engine.
It is not limited to React.
Its central architectural idea is subscriptions.
Form state changes
↓
Which consumers subscribed
to that exact state?
↓
Notify only those consumers
A field can subscribe to:
value
error
touched
dirty
active
validating
Instead of forcing every form consumer to react to every change.
React Final Form
React Final Form is the React integration for Final Form.
The official documentation describes React Final Form as a thin React wrapper over the subscription-based Final Form engine, using an Observer-style architecture so components can rerender based on the state they subscribe to.
Its early public issue activity dates to late 2017, placing it in the same broader generation as Formik's initial rise.
Do not confuse the names:
Final Form
→ Core form-state engine
React Final Form
→ React wrapper around Final Form
Install React Final Form
npm install final-form react-final-form
Complete React Final Form example
import {
Field,
Form,
} from "react-final-form";
interface RegistrationValues {
username: string;
email: string;
password: string;
}
interface RegistrationErrors {
username?: string;
email?: string;
password?: string;
}
function validate(
values: RegistrationValues
): RegistrationErrors {
const errors:
RegistrationErrors = {};
if (!values.username) {
errors.username =
"Username is required.";
} else if (
values.username.length < 3
) {
errors.username =
"Username must contain at least 3 characters.";
}
if (!values.email) {
errors.email =
"Email is required.";
} else if (
!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(
values.email
)
) {
errors.email =
"Enter a valid email address.";
}
if (!values.password) {
errors.password =
"Password is required.";
} else if (
values.password.length < 8
) {
errors.password =
"Password must contain at least 8 characters.";
}
return errors;
}
export default function ReactFinalFormExample() {
return (
<Form<RegistrationValues>
initialValues={{
username: "",
email: "",
password: "",
}}
validate={validate}
onSubmit={async (
values,
form
) => {
await new Promise(
(resolve) => {
setTimeout(resolve, 1000);
}
);
if (
values.email ===
"existing@example.com"
) {
return {
email:
"Email already exists.",
};
}
console.log(values);
form.restart();
return undefined;
}}
subscription={{
submitting: true,
pristine: true,
}}
render={({
handleSubmit,
submitting,
pristine,
}) => (
<form
className="form"
onSubmit={handleSubmit}
noValidate
>
<h1>
React Final Form
</h1>
<Field<string>
name="username"
subscription={{
value: true,
error: true,
touched: true,
}}
>
{({
input,
meta,
}) => {
const showError =
meta.touched &&
meta.error;
return (
<div className="field">
<label htmlFor="username">
Username
</label>
<input
{...input}
id="username"
aria-invalid={Boolean(
showError
)}
aria-describedby={
showError
? "username-error"
: undefined
}
/>
{showError && (
<p
id="username-error"
className="error"
>
{meta.error}
</p>
)}
</div>
);
}}
</Field>
<Field<string>
name="email"
subscription={{
value: true,
error: true,
touched: true,
}}
>
{({
input,
meta,
}) => {
const showError =
meta.touched &&
meta.error;
return (
<div className="field">
<label htmlFor="email">
Email
</label>
<input
{...input}
id="email"
type="email"
aria-invalid={Boolean(
showError
)}
aria-describedby={
showError
? "email-error"
: undefined
}
/>
{showError && (
<p
id="email-error"
className="error"
>
{meta.error}
</p>
)}
</div>
);
}}
</Field>
<Field<string>
name="password"
subscription={{
value: true,
error: true,
touched: true,
}}
>
{({
input,
meta,
}) => {
const showError =
meta.touched &&
meta.error;
return (
<div className="field">
<label htmlFor="password">
Password
</label>
<input
{...input}
id="password"
type="password"
aria-invalid={Boolean(
showError
)}
aria-describedby={
showError
? "password-error"
: undefined
}
/>
{showError && (
<p
id="password-error"
className="error"
>
{meta.error}
</p>
)}
</div>
);
}}
</Field>
<button
type="submit"
disabled={
submitting ||
pristine
}
>
{submitting
? "Registering..."
: "Register"}
</button>
</form>
)}
/>
);
}
Understanding subscriptions
The form subscribes to:
subscription={{
submitting: true,
pristine: true,
}}
That means the outer form renderer mainly cares about:
submitting
pristine
The username field subscribes to:
subscription={{
value: true,
error: true,
touched: true,
}}
It does not need every property from the complete form.
React Final Form's <Field> registers itself with the form, subscribes to field state, and receives field callbacks and metadata. The documentation also allows developers to customize exactly which state properties cause field updates.
React Final Form advantages
- Fine-grained subscriptions
- Framework-independent core
- Strong control over rerenders
- Rich field metadata
- Good support for complex state
- Can avoid broad whole-form updates
- Clear separation between form engine and React integration
React Final Form disadvantages
- Subscription concepts add a learning curve
- Render props can create visual nesting
- Incorrect subscriptions may hide needed updates
- The API may feel more explicit than simpler hook-based approaches
- Teams must understand both Final Form and its React adapter
React Hook Form
React Hook Form became prominent during the React Hooks era.
Public repository issues show active use by mid-2019, shortly after Hooks became part of mainstream React development.
Its major architectural difference is that it embraces uncontrolled native inputs while still supporting controlled components when required.
Instead of storing every keystroke in parent React state, React Hook Form can register native inputs and access their values through the DOM and refs.
We will explore its internals deeply in the next part.
Install React Hook Form
npm install react-hook-form
Complete React Hook Form preview
import {
SubmitHandler,
useForm,
} from "react-hook-form";
interface RegistrationValues {
username: string;
email: string;
password: string;
}
export default function ReactHookFormExample() {
const {
register,
handleSubmit,
setError,
reset,
formState: {
errors,
isDirty,
isSubmitting,
},
} =
useForm<RegistrationValues>({
defaultValues: {
username: "",
email: "",
password: "",
},
});
const onSubmit:
SubmitHandler<
RegistrationValues
> = async (values) => {
await new Promise(
(resolve) => {
setTimeout(resolve, 1000);
}
);
if (
values.email ===
"existing@example.com"
) {
setError("email", {
type: "server",
message:
"Email already exists.",
});
return;
}
console.log(values);
reset();
};
return (
<form
className="form"
onSubmit={
handleSubmit(onSubmit)
}
noValidate
>
<h1>
React Hook Form
</h1>
<p>
{isDirty
? "Unsaved changes"
: "No changes"}
</p>
<div className="field">
<label htmlFor="username">
Username
</label>
<input
id="username"
aria-invalid={Boolean(
errors.username
)}
aria-describedby={
errors.username
? "username-error"
: undefined
}
{...register(
"username",
{
required:
"Username is required.",
minLength: {
value: 3,
message:
"Username must contain at least 3 characters.",
},
}
)}
/>
{errors.username && (
<p
id="username-error"
className="error"
>
{
errors.username
.message
}
</p>
)}
</div>
<div className="field">
<label htmlFor="email">
Email
</label>
<input
id="email"
type="email"
aria-invalid={Boolean(
errors.email
)}
aria-describedby={
errors.email
? "email-error"
: undefined
}
{...register("email", {
required:
"Email is required.",
pattern: {
value:
/^[^\s@]+@[^\s@]+\.[^\s@]+$/,
message:
"Enter a valid email address.",
},
})}
/>
{errors.email && (
<p
id="email-error"
className="error"
>
{errors.email.message}
</p>
)}
</div>
<div className="field">
<label htmlFor="password">
Password
</label>
<input
id="password"
type="password"
aria-invalid={Boolean(
errors.password
)}
aria-describedby={
errors.password
? "password-error"
: undefined
}
{...register(
"password",
{
required:
"Password is required.",
minLength: {
value: 8,
message:
"Password must contain at least 8 characters.",
},
}
)}
/>
{errors.password && (
<p
id="password-error"
className="error"
>
{
errors.password
.message
}
</p>
)}
</div>
<button
type="submit"
disabled={isSubmitting}
>
{isSubmitting
? "Registering..."
: "Register"}
</button>
</form>
);
}
Notice what is missing:
value={values.email}
onChange={handleChange}
onBlur={handleBlur}
Instead, we write:
{...register("email")}
That is our first hint that React Hook Form uses a different approach.
React Hook Form advantages
- Less form boilerplate
- Uncontrolled-input support
- Fewer broad React state updates
- Hook-based API
- Strong TypeScript support
- Field-level and schema validation
- Server-error APIs
- Dynamic-field support
- Works with controlled components through adapters
React Hook Form disadvantages
- Uncontrolled architecture can initially feel unfamiliar
-
register()spreads several props at once - Custom controlled components require
Controlleror manual integration - Developers must understand default values and registration
- Watching many fields can introduce additional rerenders
- Some behavior depends on correct field naming
TanStack Form
TanStack Form is part of the newer generation of form libraries.
Its documentation describes it as framework-agnostic, headless, strongly TypeScript-oriented, and designed for large production forms. It provides adapters for several frontend frameworks, including React.
Unlike React Hook Form's uncontrolled-first philosophy, TanStack Form explicitly embraces controlled form state. Its documentation argues that controlled values can improve predictability, testing, conditional logic, debugging, and non-DOM usage.
This demonstrates an important engineering lesson:
There is no architecture with only advantages.
React Hook Form and TanStack Form make different trade-offs.
Install TanStack Form
npm install @tanstack/react-form
Complete TanStack Form example
import {
useForm,
} from "@tanstack/react-form";
export default function TanStackFormExample() {
const form = useForm({
defaultValues: {
username: "",
email: "",
password: "",
},
onSubmit: async ({
value,
}) => {
await new Promise(
(resolve) => {
setTimeout(resolve, 1000);
}
);
console.log(value);
},
});
return (
<form
className="form"
noValidate
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
void form.handleSubmit();
}}
>
<h1>TanStack Form</h1>
<form.Field
name="username"
validators={{
onBlur: ({ value }) => {
if (!value.trim()) {
return "Username is required.";
}
if (
value.trim().length < 3
) {
return "Username must contain at least 3 characters.";
}
return undefined;
},
}}
>
{(field) => {
const error =
field.state.meta
.isTouched
? field.state.meta
.errors[0]
: undefined;
return (
<div className="field">
<label htmlFor="username">
Username
</label>
<input
id="username"
name={field.name}
value={
field.state.value
}
onBlur={
field.handleBlur
}
onChange={(event) => {
field.handleChange(
event.target.value
);
}}
aria-invalid={Boolean(
error
)}
aria-describedby={
error
? "username-error"
: undefined
}
/>
{error && (
<p
id="username-error"
className="error"
>
{String(error)}
</p>
)}
</div>
);
}}
</form.Field>
<form.Field
name="email"
validators={{
onBlur: ({ value }) => {
if (!value.trim()) {
return "Email is required.";
}
if (
!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(
value.trim()
)
) {
return "Enter a valid email address.";
}
return undefined;
},
}}
>
{(field) => {
const error =
field.state.meta
.isTouched
? field.state.meta
.errors[0]
: undefined;
return (
<div className="field">
<label htmlFor="email">
Email
</label>
<input
id="email"
name={field.name}
type="email"
value={
field.state.value
}
onBlur={
field.handleBlur
}
onChange={(event) => {
field.handleChange(
event.target.value
);
}}
aria-invalid={Boolean(
error
)}
aria-describedby={
error
? "email-error"
: undefined
}
/>
{error && (
<p
id="email-error"
className="error"
>
{String(error)}
</p>
)}
</div>
);
}}
</form.Field>
<form.Field
name="password"
validators={{
onBlur: ({ value }) => {
if (!value) {
return "Password is required.";
}
if (value.length < 8) {
return "Password must contain at least 8 characters.";
}
return undefined;
},
}}
>
{(field) => {
const error =
field.state.meta
.isTouched
? field.state.meta
.errors[0]
: undefined;
return (
<div className="field">
<label htmlFor="password">
Password
</label>
<input
id="password"
name={field.name}
type="password"
value={
field.state.value
}
onBlur={
field.handleBlur
}
onChange={(event) => {
field.handleChange(
event.target.value
);
}}
aria-invalid={Boolean(
error
)}
aria-describedby={
error
? "password-error"
: undefined
}
/>
{error && (
<p
id="password-error"
className="error"
>
{String(error)}
</p>
)}
</div>
);
}}
</form.Field>
<form.Subscribe
selector={(state) => [
state.canSubmit,
state.isSubmitting,
state.isDirty,
]}
>
{([
canSubmit,
isSubmitting,
isDirty,
]) => (
<>
<p>
{isDirty
? "Unsaved changes"
: "No changes"}
</p>
<button
type="submit"
disabled={
!canSubmit ||
isSubmitting
}
>
{isSubmitting
? "Registering..."
: "Register"}
</button>
</>
)}
</form.Subscribe>
</form>
);
}
TanStack Form supports field-level and form-level validation, multiple validation timings, and synchronous or asynchronous validation.
TanStack Form advantages
- Strong type inference
- Framework-agnostic core
- Headless architecture
- Fine-grained subscriptions
- Controlled and predictable state
- Flexible validation timing
- Designed for composition and large applications
- Useful when forms are central to the product
TanStack Form disadvantages
- More concepts to learn
- APIs can feel verbose for small forms
- Controlled architecture still involves value-driven updates
- A newer ecosystem than long-established alternatives
- May be unnecessary for simple login or search forms
Form-library comparison
| Library | Main architecture | Input style | Main strength | Main trade-off |
|---|---|---|---|---|
| Formik | Central React form state | Mostly controlled | Familiar, mature mental model | Can create broad rerenders and boilerplate |
| Final Form | Framework-independent subscription engine | Adapter-dependent | Fine-grained subscriptions | More architecture to understand |
| React Final Form | React wrapper over Final Form | Usually controlled through field props | Precise rerender subscriptions | Render-prop and subscription complexity |
| React Hook Form | Registration, refs, subscriptions | Uncontrolled-first | Low boilerplate and fewer React updates | Different mental model; controlled components need adapters |
| TanStack Form | Typed controlled form engine with subscriptions | Controlled | Type safety, composition, framework support | More concepts and verbosity |
When did each approach become popular?
Rather than treating popularity as a single release date, it is better to think in ecosystem generations.
Early to mid-2010s
└── Hand-written controlled React forms
Late 2010s
├── Formik becomes a common default
└── Final Form / React Final Form introduce
subscription-focused alternatives
React Hooks era, from 2019 onward
└── React Hook Form grows around hooks,
registration, refs, and uncontrolled inputs
2020s
└── TypeScript-first and framework-agnostic
systems such as TanStack Form expand
the design space
Repository activity and official resources support Formik and React Final Form adoption beginning around 2017, while React Hook Form had an active public user base by 2019.
Popularity does not automatically determine the best choice for your project.
Which library should you choose?
Choose plain React state when:
- The form has one to three fields
- Validation is simple
- There are no dynamic arrays
- You do not need elaborate touched or dirty state
- Adding a dependency would provide little value
Examples:
Search box
Newsletter subscription
Simple filter
Two-field login
Consider Formik when:
- The team already uses Formik
- Existing components are built around it
- Developers understand its conventions
- Migrating would provide little measurable benefit
- Controlled state matches the product requirements
Do not rewrite stable forms merely because another library is newer.
Consider React Final Form when:
- Fine-grained subscriptions are valuable
- The team understands subscription-based state
- Existing architecture uses Final Form
- Form state must be managed independently of React
- Precise control over updates matters
Consider React Hook Form when:
- You want low boilerplate
- Forms mostly use native inputs
- Performance is important
- You need Zod or another schema resolver
- The project uses TypeScript
- You need dynamic field arrays
- You want clear server-error integration
Consider TanStack Form when:
- Forms are central to the application
- Type safety is a major priority
- The project needs complex validation timing
- Framework-independent architecture matters
- The team values controlled predictability
- The team accepts a larger learning investment
Common beginner mistake: choosing from benchmark charts alone
A form library may advertise fewer rerenders or smaller bundles.
Those factors matter, but they are not the complete decision.
Also evaluate:
Team knowledge
Documentation quality
Accessibility patterns
Validation integration
Design-system compatibility
Testing strategy
Migration cost
Long-term maintenance
Complex-field support
Server-error handling
The fastest library in a small artificial benchmark is not automatically the safest architectural choice for your production application.
Common beginner mistake: installing multiple form libraries
Avoid using:
Formik in one new form
React Hook Form in another
TanStack Form in another
Custom useState in every remaining form
without a clear reason.
This creates:
- Inconsistent error handling
- Different validation timing
- Different reusable components
- More dependencies
- More onboarding difficulty
- More testing patterns
A team should usually establish a default approach while allowing justified exceptions.
Common beginner mistake: expecting the library to provide security
Form libraries improve frontend behavior.
They do not replace:
- Server-side validation
- Authorization
- Rate limiting
- CSRF defenses where required
- Secure cookies
- Password hashing
- Database constraints
- File scanning
- Output encoding
Even a perfectly validated React form can be bypassed by calling the API directly.
Senior engineer tip: separate concerns
A form usually contains at least four layers.
1. Presentation
Labels, inputs, errors, layout
2. Form state
Values, touched, dirty, submitting
3. Validation
Required fields, formats, business rules
4. Data submission
API requests, server errors, retries
Avoid combining everything in one component.
A healthier direction looks like:
RegistrationForm
├── registrationSchema
├── registerUser API function
├── reusable Input component
└── form-library integration
Later in this series, we will implement that structure using:
React Hook Form
+
Zod
+
TypeScript
+
Express backend
Interview questions
1. Why do React form libraries exist?
They reduce repeated code for:
- Values
- Errors
- Touched state
- Dirty state
- Validation
- Submission
- Resetting
- Dynamic fields
- Nested data
They also provide consistent APIs and performance strategies.
2. What is the difference between Formik and React Hook Form?
Formik commonly follows a controlled, React-state-oriented model.
React Hook Form is uncontrolled-first and registers inputs using refs and event handlers, reducing the need to store every keystroke in parent React state.
3. What is the difference between Final Form and React Final Form?
Final Form is the framework-independent form-state engine.
React Final Form is the React integration built on top of Final Form.
4. What is a subscription-based form library?
A subscription-based library allows fields or components to listen only to specific parts of form state.
For example, an email error component may subscribe to:
email.error
email.touched
It does not need to update when the password value changes.
5. Should every React form use a library?
No.
A small form may be simpler with useState or native form APIs.
Use a library when it removes more complexity than it introduces.
Performance comparison
Manual controlled form
Email changes
↓
Parent state updates
↓
Parent component runs
↓
All child JSX is recalculated
Subscription-based form
Email changes
↓
Form store updates email
↓
Email subscribers are notified
↓
Only relevant consumers update
Uncontrolled-first form
Email changes
↓
Browser input stores value
↓
Form library observes/registers field
↓
React rerender happens only when
subscribed form state must change
These diagrams are simplified.
Real performance depends on:
- Component boundaries
- Subscriptions
- Validation mode
- Watched fields
- Controlled third-party inputs
- Parent rerenders
- Derived calculations
- Application size
Part 2 summary
We began with a manual form that scaled poorly.
DOM queries
Validation functions
Error elements
Event listeners
Loading flags
Nested data
Dynamic fields
Then React introduced declarative, state-driven UI.
User input
↓
Update React state
↓
Component runs
↓
UI is derived from state
Controlled forms gave us predictability, but large forms required us to manually manage:
values
errors
touched
dirtyFields
isDirty
isValid
isSubmitting
reset
validation
Form libraries emerged because teams were rebuilding the same infrastructure repeatedly.
Manual controlled forms
↓
Repeated state-management patterns
↓
Formik and Final Form generation
↓
Hooks and uncontrolled-first approaches
↓
Modern typed form engines
The major lesson is:
A form library does not remove form complexity. It organizes and abstracts that complexity.
In the next part, we will examine React Hook Form in detail.
We will cover:
Stage 9
React Hook Form philosophy and architecture
Stage 10
useForm, register, handleSubmit, watch,
setValue, getValues, reset, trigger,
Controller, formState, and more
Stage 11
Yup, Zod, Valibot, Vest, and manual validation
Stage 12
React Hook Form with Zod and TypeScript
Top comments (0)