Forms become complex very quickly in enterprise applications. A real form can contain dozens of fields, conditional sections, dependent fields, client validation, server validation, asynchronous checks, file uploads, default values, edit mode, submission state, and business rules. This chapter builds a realistic employee-registration form using React Hook Form and demonstrates how the UI behaves.
Why manual form state becomes difficult
With a small form, separate useState variables are manageable. With twenty or thirty fields, manually creating state, change handlers, touched state, validation messages, and submit logic becomes repetitive and error-prone.
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [email, setEmail] = useState("");
const [phone, setPhone] = useState("");
const [department, setDepartment] = useState("");
const [salary, setSalary] = useState(0);
// ...many more fields
Install React Hook Form
npm install react-hook-formFor schema validation, a common option is Zod with a resolver.
npm install zod @hookform/resolvers
Complete employee form
import { useForm } from "react-hook-form";
function EmployeeForm() {
const {
register,
handleSubmit,
formState: {
errors,
isSubmitting,
isDirty,
isValid
}
} = useForm({
mode: "onChange",
defaultValues: {
firstName: "",
lastName: "",
email: "",
department: "",
experience: 0
}
});
async function onSubmit(data) {
console.log(data);
await saveEmployee(data);
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input
{...register("firstName", {
required: "First name is required"
})}
/>
{errors.firstName && (
<p>{errors.firstName.message}</p>
)}
<input
{...register("email", {
required: "Email is required",
pattern: {
value: /\S+@\S+\.\S+/,
message: "Invalid email"
}
})}
/>
{errors.email && (
<p>{errors.email.message}</p>
)}
<button
disabled={isSubmitting || !isValid}
>
{isSubmitting ? "Saving..." : "Save Employee"}
</button>
</form>
);
}
What register actually does
register connects the input to React Hook Form. It supplies the event handlers, field name, and reference information required by the form library.
const registration = register("email");
console.log(registration);
// Conceptually contains:
// name
// onChange
// onBlur
// ref
Validation rules
Rules can be attached to individual fields.
register("experience", {
required: "Experience is required",
min: {
value: 0,
message: "Cannot be negative"
},
max: {
value: 50,
message: "Enter a realistic value"
},
valueAsNumber: true
})
Complete validation output
User clicks Save Employee
First name is empty:
First name is required
Email is invalid:
Invalid email
Experience is -2:
Cannot be negative
All fields valid:
Saving...
API succeeds:
Employee created successfully
Touched, dirty, valid, submitting
Real forms need more than a value. React Hook Form exposes useful state such as whether the user changed a field, whether validation has passed, and whether submission is running.
const {
errors,
isDirty,
isValid,
isSubmitting
} = formState;
Conditional fields
Business forms often have conditional sections. For example, contractors may need a contract-end date while permanent employees do not.
const employmentType = watch("employmentType");
{employmentType === "contract" && (
<input
type="date"
{...register("contractEndDate", {
required: "Contract end date is required"
})}
/>
)}
Dependent validation
Some rules depend on another field. For example, a confirm-password field must match the password.
<input
type="password"
{...register("password", {
required: "Password is required",
minLength: {
value: 8,
message: "Minimum 8 characters"
}
})}
/>
<input
type="password"
{...register("confirmPassword", {
validate: value =>
value === watch("password") ||
"Passwords do not match"
})}
/>
Schema validation with Zod
import { z } from "zod";
const employeeSchema = z.object({
firstName: z
.string()
.min(1, "First name is required"),
lastName: z
.string()
.min(1, "Last name is required"),
email: z
.string()
.email("Invalid email"),
experience: z
.number()
.min(0)
.max(50)
});
Connect the schema
import { zodResolver } from "@hookform/resolvers/zod";
const form = useForm({
resolver: zodResolver(employeeSchema),
defaultValues: {
firstName: "",
lastName: "",
email: "",
experience: 0
}
});
Why schema validation helps
When validation becomes complex, keeping rules in one schema makes them easier to review and test. It can also reduce duplication between related forms. The backend still needs its own validation.
Editing an existing record
Edit screens usually receive data from an API after the form has been created. Use reset to populate the form with the loaded record.
useEffect(() => {
async function loadEmployee() {
const employee = await getEmployee(id);
reset({
firstName: employee.firstName,
lastName: employee.lastName,
email: employee.email,
experience: employee.experience
});
}
loadEmployee();
}, [id, reset]);
Server-side validation
Client validation cannot know every business rule. For example, an email may have a valid format but already belong to another employee. The backend may return field-specific errors.
try {
await createEmployee(data);
} catch (error) {
if (error.code === "EMAIL_EXISTS") {
setError("email", {
type: "server",
message: "Email is already registered"
});
}
}
Complete form submission flow
flowchart TD
A[User enters data] --> B[React Hook Form]
B --> C[Client validation]
C --> D{Valid}
D -->|No| E[Show field errors]
D -->|Yes| F[Submit API request]
F --> G{Server result}
G -->|Validation error| H[Set field errors]
G -->|Success| I[Show success]
G -->|Failure| J[Show general error]
Loading and double-submit prevention
Users can click a submit button more than once. Disable the button while the request is in progress and make the backend operation safe against accidental duplicate submissions where appropriate.
<button disabled={isSubmitting}>
{isSubmitting ? "Saving..." : "Save"}
</button>
Reset after success
async function onSubmit(data) {
await createEmployee(data);
reset();
showMessage("Employee created successfully");
}
File upload
File inputs can be registered, but uploading files normally requires constructing multipart form data or using an upload API designed for the application's requirements.
const file = watch("resume")?.[0];
const formData = new FormData();
formData.append("resume", file);
await fetch("/api/employees/resume", {
method: "POST",
body: formData
});
Large form component design
Do not put a 1,000-line form component into a single file. Break the UI into reusable sections such as PersonalDetails, EmploymentDetails, Compensation, and Documents while keeping one form context where appropriate.
function EmployeeForm() {
const methods = useForm();
return (
<FormProvider {...methods}>
<PersonalDetails />
<EmploymentDetails />
<Compensation />
<Documents />
</FormProvider>
);
}
Common mistakes
- Using client validation as a security boundary.
- Not handling server validation.
- Allowing repeated submission while an API request is active.
- Hardcoding error display differently for every field.
- Keeping a huge form in one component.
- Forgetting to reset edit forms when the record changes.
Interview questions
- Why React Hook Form?
- What does register do?
- How do you handle validation errors?
- How do you integrate Zod or another schema validator?
- How do you handle server validation?
- How do you implement conditional fields?
- How do you prevent duplicate submissions?
Key takeaways
- Use a form library when form complexity justifies it.
- Keep client and server validation separate.
- Handle field errors, server errors, loading, success, and reset behavior.
- For large forms, organize UI into reusable sections.