The Evolution of Web Forms — Part 3: React Hook Form, Validation Libraries, and Zod
In Part 2, we learned that React solved the problem of manually updating the DOM.
Instead of writing:
emailError.textContent =
"Email already exists";
emailInput.setAttribute(
"aria-invalid",
"true"
);
React allowed us to describe the interface from state:
<input
aria-invalid={Boolean(errors.email)}
/>
{errors.email && (
<p>{errors.email}</p>
)}
However, React did not automatically manage:
- Form values
- Validation errors
- Touched fields
- Dirty fields
- Submission state
- Reset behavior
- Dynamic fields
- Backend errors
- Performance
Developers still had to build those features manually.
That created the need for form-management libraries.
This part covers:
- React Hook Form’s philosophy and architecture
- React Hook Form’s core APIs
- Validation libraries
- React Hook Form with Zod and TypeScript
By the end, we will build a production-style registration form using:
React
+
TypeScript
+
React Hook Form
+
Zod
+
An API layer
Stage 9: React Hook Form Deep Dive
React Hook Form is not simply a shorter way to write controlled React forms.
It uses a different architectural philosophy.
A traditional controlled input stores its value in React state:
const [email, setEmail] =
useState("");
<input
value={email}
onChange={(event) => {
setEmail(event.target.value);
}}
/>
Every keystroke produces a state update:
User types
↓
onChange runs
↓
setEmail runs
↓
Component renders again
↓
Input receives the new value
React Hook Form prefers native, uncontrolled inputs when possible.
<input
{...register("email")}
/>
The browser stores the current value inside the input element.
React Hook Form registers the input, listens to its events, tracks relevant form state, and reads its value when required. React Hook Form’s official documentation describes register() as the mechanism that connects an input to validation, value tracking, and submission.
Controlled versus uncontrolled inputs
Controlled input
const [email, setEmail] =
useState("");
<input
value={email}
onChange={(event) => {
setEmail(event.target.value);
}}
/>
React owns the value.
React state
↓
input value
The input cannot independently keep a different value because React continuously supplies it.
Uncontrolled input
<input
name="email"
defaultValue=""
/>
The browser owns the current value.
DOM input element
↓
current value
React may provide the initial value, but it does not need to update React state after every keystroke.
The current value can be read through:
- A ref
FormData- Native form submission
- A form-management library
A native uncontrolled input using useRef
Before understanding React Hook Form, let us manually build one uncontrolled input.
import {
FormEvent,
useRef,
} from "react";
export default function UncontrolledForm() {
const emailRef =
useRef<HTMLInputElement>(null);
function handleSubmit(
event: FormEvent<HTMLFormElement>
) {
event.preventDefault();
const email =
emailRef.current?.value ?? "";
console.log({ email });
}
return (
<form onSubmit={handleSubmit}>
<label htmlFor="email">
Email
</label>
<input
id="email"
name="email"
type="email"
ref={emailRef}
/>
<button type="submit">
Submit
</button>
</form>
);
}
Notice what is missing:
value={email}
onChange={handleChange}
The browser stores the entered value.
On submission, we access the element through:
emailRef.current
and read:
emailRef.current.value
What is a ref?
A ref gives JavaScript access to an element or another persistent value.
const emailRef =
useRef<HTMLInputElement>(null);
After React connects the ref to the input:
<input ref={emailRef} />
the ref may contain the actual DOM element:
emailRef.current
↓
HTMLInputElement
We can then access browser properties:
emailRef.current?.value;
emailRef.current?.focus();
emailRef.current?.disabled;
emailRef.current?.files;
React Hook Form uses refs as part of registering native inputs.
What does register() return?
Consider:
const {
register,
} = useForm();
const registration =
register("email");
console.log(registration);
Conceptually, the returned object resembles:
{
name: "email",
onChange: function,
onBlur: function,
ref: function
}
When we write:
<input
{...register("email")}
/>
the spread operator applies those properties to the input.
Conceptually, it becomes:
<input
name="email"
onChange={registeredOnChange}
onBlur={registeredOnBlur}
ref={registeredRef}
/>
React Hook Form can now:
- Identify the field by name
- Track changes
- Track blur events
- Access the input element
- Read the field value
- Run validation
- Focus the input after an error
- Include the value during submission
Why the field name matters
register("email")
registers a field under the key:
email
The submitted object becomes:
{
email: "karthik@example.com"
}
A nested field name:
register("address.city")
can produce:
{
address: {
city: "Kadapa"
}
}
An array-style field:
register(
"experiences.0.company"
)
can produce:
{
experiences: [
{
company: "Example Company"
}
]
}
The name is not merely an HTML detail.
It is the path React Hook Form uses to organize the form data and errors.
React Hook Form’s conceptual architecture
The exact internal implementation can change between versions, so application code should not depend on private internals.
However, the public architecture can be understood conceptually.
useForm()
↓
Form control object
↓
┌──────────────┼──────────────┐
↓ ↓ ↓
Registered fields Form state Subscriptions
↓ ↓ ↓
name, ref, errors, Notify only
events, rules dirty, etc. interested UI
↓
Native DOM inputs
store current values
When an input changes:
User types
↓
Native input value changes
↓
Registered onChange runs
↓
React Hook Form updates internal field state
↓
Validation may run
↓
Only subscribed form state is notified
The browser can keep the current input value without requiring the parent component to store that value in React state after every keystroke.
Why fewer rerenders can happen
In a manually controlled form:
User types into email
↓
setValues()
↓
Form component renders
↓
All JSX inside the form is recalculated
With React Hook Form and a native registered input:
User types into email
↓
DOM input stores the value
↓
React Hook Form records relevant changes
↓
Only subscribed form-state consumers
need to update
This does not mean React Hook Form never rerenders.
Rerenders can still happen when:
- An error appears or disappears
-
isDirtychanges -
isValidchanges - A watched value changes
- A conditional field is rendered
- Submission state changes
- A controlled component uses
Controller - The parent component rerenders for another reason
The important difference is that the input value does not always need to be copied into parent React state on every keystroke.
React Hook Form’s formState is subscription-oriented, and its documentation notes that returned form state is wrapped with a Proxy so unused state properties can avoid unnecessary work.
Understanding form-state subscriptions
Suppose a component reads:
const {
formState: {
errors,
},
} = useForm();
The component is interested in errors.
If it also reads:
const {
formState: {
errors,
isDirty,
isSubmitting,
},
} = useForm();
it is interested in three pieces of form state.
Conceptually:
Component subscribes to:
├── errors
├── isDirty
└── isSubmitting
This is one reason destructuring the state that the component needs is important.
React Hook Form does not eliminate state
A common misunderstanding is:
React Hook Form does not use state.
That is not accurate.
React Hook Form still manages state such as:
errors
dirty fields
touched fields
submission state
validation state
registered fields
default values
The difference is where the state is stored, how it is updated, and which components are notified.
Complete React Hook Form example
Install React Hook Form:
npm install react-hook-form
Create src/App.tsx:
import {
SubmitHandler,
useForm,
} from "react-hook-form";
interface RegistrationValues {
username: string;
email: string;
password: string;
}
const defaultValues:
RegistrationValues = {
username: "",
email: "",
password: "",
};
export default function App() {
const {
register,
handleSubmit,
reset,
formState: {
errors,
isDirty,
isSubmitting,
},
} =
useForm<RegistrationValues>({
defaultValues,
mode: "onBlur",
});
const onSubmit:
SubmitHandler<
RegistrationValues
> = async (values) => {
await new Promise(
(resolve) => {
setTimeout(resolve, 1000);
}
);
console.log(
"Submitted values:",
values
);
reset();
};
console.log(
"Registration form rendered"
);
return (
<main className="page">
<form
className="form"
onSubmit={
handleSubmit(onSubmit)
}
noValidate
>
<h1>Create an account</h1>
<p>
{isDirty
? "You have unsaved changes."
: "No changes yet."}
</p>
<div className="field">
<label htmlFor="username">
Username
</label>
<input
id="username"
type="text"
autoComplete="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.",
},
maxLength: {
value: 20,
message:
"Username cannot exceed 20 characters.",
},
pattern: {
value:
/^[A-Za-z0-9_]+$/,
message:
"Use only letters, numbers, and underscores.",
},
}
)}
/>
{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"
autoComplete="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"
autoComplete="new-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>
<div className="actions">
<button
type="button"
onClick={() => {
reset();
}}
disabled={
isSubmitting ||
!isDirty
}
>
Reset
</button>
<button
type="submit"
disabled={isSubmitting}
>
{isSubmitting
? "Creating account..."
: "Register"}
</button>
</div>
</form>
</main>
);
}
Add src/index.css:
* {
box-sizing: border-box;
}
body {
margin: 0;
background: #f4f4f5;
font-family:
Inter,
Arial,
sans-serif;
}
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 #71717a;
border-radius: 6px;
}
input[aria-invalid="true"] {
border-color: #b91c1c;
}
.error {
margin: 0;
color: #b91c1c;
font-size: 14px;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 12px;
}
button {
padding: 10px 14px;
cursor: pointer;
}
button:disabled {
cursor: not-allowed;
opacity: 0.65;
}
Controlled React versus React Hook Form
Controlled form
const [email, setEmail] =
useState("");
<input
value={email}
onChange={(event) => {
setEmail(event.target.value);
}}
/>
React Hook Form
<input
{...register("email")}
/>
Comparison
| Concern | Controlled React | React Hook Form |
|---|---|---|
| Current native input value | React state | Usually the DOM input |
| Change handler | Manually written | Supplied by register()
|
| Blur handler | Manually written | Supplied by register()
|
| Field ref | Manually managed | Supplied by register()
|
| Errors | Manually stored | formState.errors |
| Dirty state | Manually calculated |
isDirty, dirtyFields
|
| Touched state | Manually calculated | touchedFields |
| Submission | Manually validated | handleSubmit() |
| Reset | Manually coordinated | reset() |
| Server errors | Manually mapped | setError() |
Advantages of React Hook Form’s architecture
- Less boilerplate for native inputs
- Input values do not always require parent state
- Built-in dirty and touched tracking
- Field-level validation
- Nested field support
- Better support for large forms
- Easy integration with schema validators
- Type-safe field names with TypeScript
- Built-in focus management for many errors
- APIs for server-side errors
Disadvantages
- The uncontrolled model may initially feel unfamiliar
-
register()hides several props inside a spread - Custom controlled components require additional integration
- Broad use of
watch()can cause more rerenders - Incorrect default values can produce confusing dirty-state behavior
- Dynamic forms require careful field naming
- Understanding subscriptions is still necessary for optimization
Common beginner mistake: overwriting registered handlers
Consider:
<input
{...register("email")}
onChange={handleEmailChange}
/>
The later onChange can replace the handler supplied by register().
A safer approach is to place custom behavior in the registration options:
<input
{...register("email", {
onChange: (event) => {
console.log(
event.target.value
);
},
})}
/>
Or explicitly compose the handlers.
Common beginner mistake: losing the ref
A reusable input component must forward the supplied ref if it is used with register().
If the ref stops at the React component and never reaches the real input, React Hook Form may not be able to register the element correctly.
Interview question
Why can React Hook Form cause fewer rerenders than a traditional controlled form?
A traditional controlled form usually updates React state after every keystroke.
React Hook Form can let the native input retain its current value while tracking form state through registration, refs, events, and subscriptions. React components then update mainly when subscribed state such as errors, dirty status, watched values, or submission state changes.
Why this evolved
React Hook Form reduced form boilerplate and avoided forcing every native input value through parent React state. However, developers still needed to understand its public APIs for reading values, setting errors, controlling validation, resetting fields, and integrating non-native components.
Stage 10: React Hook Form APIs
React Hook Form exposes many methods.
Do not try to memorize all of them at once.
Instead, organize them by responsibility.
Form creation
└── useForm
Field connection
├── register
├── control
└── Controller
Submission
└── handleSubmit
Read values
├── watch
└── getValues
Change values
└── setValue
Errors and validation
├── setError
├── clearErrors
└── trigger
Reset state
├── reset
└── resetField
Form status
└── formState
The official useForm() hook initializes the form and exposes these methods and state objects.
1. useForm()
useForm() creates the form-control system.
const form =
useForm<RegistrationValues>();
Most applications destructure the required methods:
const {
register,
handleSubmit,
formState: {
errors,
},
} =
useForm<RegistrationValues>();
Important useForm() options
useForm<RegistrationValues>({
defaultValues: {
username: "",
email: "",
password: "",
},
mode: "onBlur",
shouldUnregister: false,
});
Important options include:
defaultValuesmodereValidateModeresolvershouldUnregistercriteriaModecontextdisabled
2. defaultValues
const defaultValues = {
username: "",
email: "",
password: "",
};
useForm({
defaultValues,
});
Default values serve as the baseline for:
- Initial field values
- Reset behavior
- Dirty comparison
Default email:
""
Current email:
"karthik@example.com"
isDirty:
true
If the current value returns to the default:
Current email:
""
isDirty:
false
Use consistent values.
For text inputs, prefer:
email: ""
instead of:
email: undefined
Asynchronous default values
When editing existing data, default values may come from an API.
useForm<UserFormValues>({
defaultValues: async () => {
const response =
await fetch("/api/me");
if (!response.ok) {
throw new Error(
"Unable to load user"
);
}
return response.json();
},
});
For externally loaded data, reset() is also commonly used after the data arrives.
3. mode
mode controls when initial validation runs.
useForm({
mode: "onSubmit",
});
Common modes include:
| Mode | Validation timing |
|---|---|
onSubmit |
When the user submits |
onBlur |
When the user leaves a field |
onChange |
As the value changes |
onTouched |
After initial interaction |
all |
Blur and change interactions |
Example:
useForm({
mode: "onBlur",
});
This provides a balanced experience:
User types
↓
No immediate interruption
↓
User leaves field
↓
Validate field
Using onChange can provide immediate feedback, but it may also create more validation work and a noisier user experience.
4. shouldUnregister
Imagine a conditional field:
{hasCompany && (
<input
{...register(
"companyName"
)}
/>
)}
When companyName disappears, should its value remain in the form?
With the default preservation-oriented behavior:
shouldUnregister: false
an unmounted field can remain represented in form data.
With:
shouldUnregister: true
an unmounted field is removed from registration and its value is not retained in the same way. React Hook Form documents shouldUnregister as controlling whether fields are removed after unmount.
Use true when hidden fields should behave like native fields that no longer exist.
Use false when temporarily hidden steps should preserve their data.
5. register()
register() connects a native field to React Hook Form.
<input
{...register("email")}
/>
It can also accept validation rules:
<input
{...register("email", {
required:
"Email is required.",
pattern: {
value:
/^[^\s@]+@[^\s@]+\.[^\s@]+$/,
message:
"Enter a valid email address.",
},
})}
/>
Common registration rules include:
register("age", {
required: "Age is required.",
min: {
value: 18,
message:
"You must be at least 18.",
},
max: {
value: 100,
message:
"Enter a valid age.",
},
valueAsNumber: true,
validate: (value) => {
return (
Number.isInteger(value) ||
"Age must be a whole number."
);
},
});
Custom validation with validate
register("username", {
validate: {
noSpaces: (value) => {
return (
!value.includes(" ") ||
"Username cannot contain spaces."
);
},
notAdmin: (value) => {
return (
value.toLowerCase() !==
"admin" ||
"This username is reserved."
);
},
},
});
A validation function can return:
true
when valid, or:
an error message
when invalid.
6. handleSubmit()
handleSubmit() coordinates validation and submission.
<form
onSubmit={
handleSubmit(onSubmit)
}
>
The success callback receives validated values:
const onSubmit = (
values: RegistrationValues
) => {
console.log(values);
};
You can also provide an invalid callback:
const onInvalid = (
errors: FieldErrors<
RegistrationValues
>
) => {
console.log(
"Validation failed:",
errors
);
};
<form
onSubmit={
handleSubmit(
onSubmit,
onInvalid
)
}
/>
The process is:
Native submit event
↓
handleSubmit
↓
Run validation
/ \
Invalid Valid
↓ ↓
onInvalid onSubmit
handleSubmit() validates before invoking the success callback and can pass typed form data to it.
7. watch()
watch() subscribes to value changes.
const country =
watch("country");
You can use the value for conditional rendering:
{country === "india" && (
<input
{...register("state")}
/>
)}
Watch multiple fields:
const [
password,
confirmPassword,
] = watch([
"password",
"confirmPassword",
]);
Watch the entire form:
const values = watch();
Be careful with broad watches.
Watching the complete form means the component may update for changes across all watched fields.
React Hook Form documents watch() as a method for observing field values and rendering conditional UI.
watch() versus getValues()
These methods can return similar data but have different purposes.
const email =
watch("email");
watch() subscribes to changes.
const email =
getValues("email");
getValues() reads the current value without subscribing the component to value changes. React Hook Form specifically documents getValues() as reading values without subscribing to rerenders.
Use:
watch()
when the UI must react to changes.
Use:
getValues()
when you only need a value at a particular moment.
8. getValues()
Read every field:
const values =
getValues();
Read one field:
const email =
getValues("email");
Read several fields:
const [
email,
username,
] = getValues([
"email",
"username",
]);
Example:
function handlePreview() {
const values =
getValues();
console.log(
"Preview:",
values
);
}
<button
type="button"
onClick={handlePreview}
>
Preview current data
</button>
9. setValue()
setValue() updates a field programmatically.
setValue(
"email",
"karthik@example.com"
);
Options can update related form state:
setValue(
"email",
"karthik@example.com",
{
shouldValidate: true,
shouldDirty: true,
shouldTouch: true,
}
);
React Hook Form supports using setValue() to change a registered value while optionally validating it or marking it dirty and touched.
Production examples for setValue()
Selecting an address
function selectAddress(
address: Address
) {
setValue(
"address.city",
address.city,
{
shouldDirty: true,
}
);
}
OCR document extraction
setValue(
"fullName",
extractedDocument.name,
{
shouldDirty: true,
shouldValidate: true,
}
);
Choosing a suggested username
setValue(
"username",
suggestedUsername,
{
shouldDirty: true,
shouldValidate: true,
}
);
10. setError()
setError() manually inserts an error.
setError("email", {
type: "server",
message:
"Email already exists.",
});
The result becomes available through:
errors.email
You can then render:
{errors.email && (
<p>
{errors.email.message}
</p>
)}
React Hook Form explicitly supports setError() for custom and server-side validation errors.
Root-level errors
Not every error belongs to one field.
setError("root.server", {
type: "server",
message:
"The service is temporarily unavailable.",
});
Display it:
{errors.root?.server && (
<p role="alert">
{
errors.root.server
.message
}
</p>
)}
Examples of root errors:
- Server unavailable
- Unknown registration failure
- Payment provider failure
- Session expired
- Too many requests
- Unexpected response
11. clearErrors()
Clear one error:
clearErrors("email");
Clear multiple errors:
clearErrors([
"email",
"username",
]);
Clear all errors:
clearErrors();
React Hook Form documents clearErrors() as clearing one, several, or all current errors without itself rerunning validation.
Example:
<input
{...register("email", {
onChange: () => {
clearErrors(
"root.server"
);
},
})}
/>
Use this when a global server error should disappear after the user begins correcting the form.
Do not clear a field error merely to make the UI look valid.
The value should still be revalidated when appropriate.
12. reset()
Reset the complete form:
reset();
Reset with new values:
reset({
username: "karthik",
email:
"karthik@example.com",
password: "",
});
This can be useful after fetching existing data:
useEffect(() => {
if (user) {
reset({
username:
user.username,
email:
user.email,
password: "",
});
}
}, [user, reset]);
React Hook Form’s reset() API can restore values and form-state properties such as errors, touched fields, and dirty fields according to its supplied options.
Reset while preserving selected state
reset(
{
username: "",
email: "",
password: "",
},
{
keepErrors: true,
keepDirty: true,
}
);
Use preservation options carefully.
After a successful registration, the common behavior is:
reset();
After an API refresh, you may intentionally preserve dirty user edits.
13. resetField()
Reset only one field:
resetField("email");
Reset to a new default value:
resetField("email", {
defaultValue:
"new@example.com",
});
Optionally preserve state:
resetField("email", {
keepError: true,
keepDirty: true,
keepTouched: true,
});
React Hook Form documents resetField() as resetting one field’s value and state independently of the complete form.
14. trigger()
trigger() manually runs validation.
Validate the entire form:
const isValid =
await trigger();
Validate one field:
const emailIsValid =
await trigger("email");
Validate several fields:
const credentialsAreValid =
await trigger([
"email",
"password",
]);
React Hook Form notes that trigger() is particularly useful when one field depends on another.
Multi-step form example
async function goToNextStep() {
const stepIsValid =
await trigger([
"firstName",
"lastName",
"email",
]);
if (!stepIsValid) {
return;
}
setCurrentStep(2);
}
Only move forward when the current step is valid.
15. control
control is the internal public control object used by advanced React Hook Form APIs.
const {
control,
} = useForm();
You normally pass it to:
ControlleruseControlleruseWatchuseFieldArrayuseFormState
Do not directly modify control.
React Hook Form documents control as the object used to register and coordinate components with the form system.
16. Controller
Native HTML inputs work well with register().
However, some UI components are controlled components.
They may expose an API such as:
<RolePicker
value={role}
onChange={setRole}
/>
They do not expose a native input ref in the way register() expects.
Controller connects these components to React Hook Form.
<Controller
name="role"
control={control}
render={({
field,
fieldState,
}) => (
<RolePicker
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
error={
fieldState.error
?.message
}
/>
)}
/>
Controller supplies:
field
├── name
├── value
├── onChange
├── onBlur
├── ref
└── disabled
fieldState
├── error
├── invalid
├── isDirty
└── isTouched
React Hook Form’s documentation places isTouched on an individual controller field’s fieldState.
Important correction: isTouched versus touchedFields
There is no commonly used top-level:
formState.isTouched
Instead, React Hook Form provides:
formState.touchedFields
for the collection of touched fields.
For one controlled field, Controller provides:
fieldState.isTouched
Conceptually:
Whole form:
touchedFields.email
One Controller field:
fieldState.isTouched
17. resolver
A resolver connects React Hook Form to an external validation library.
useForm({
resolver:
zodResolver(schema),
});
The resolver:
- Receives the form values.
- Passes them to the validation library.
- Receives validation results.
- Converts errors into React Hook Form’s error structure.
- Returns validated or transformed values.
React Hook Form’s resolver package supports several schema and validation libraries, including Zod, Yup, Valibot, and Vest.
We will implement this fully in Stage 12.
18. formState
const {
formState,
} = useForm();
Common properties include:
const {
errors,
isDirty,
dirtyFields,
touchedFields,
isSubmitting,
isValid,
isSubmitted,
isSubmitSuccessful,
submitCount,
defaultValues,
} = formState;
React Hook Form documents formState as the source of current errors, dirty status, touched fields, submission status, and validity.
errors
errors.email
Possible shape:
{
type: "required",
message:
"Email is required.",
ref: HTMLInputElement
}
Render:
{errors.email && (
<p>
{errors.email.message}
</p>
)}
isDirty
isDirty
Indicates whether the form currently differs from its default values.
<button
disabled={!isDirty}
>
Save changes
</button>
dirtyFields
dirtyFields
Identifies individual changed fields.
Example:
{
email: true,
address: {
city: true
}
}
Use it for:
- Autosaving selected fields
- Showing changed-field indicators
- Building partial update requests
- Warning about unsaved changes
touchedFields
touchedFields
Tracks fields that have been interacted with according to the form’s event lifecycle.
Example:
{
email: true,
password: true
}
isSubmitting
isSubmitting
Becomes true while an asynchronous submit callback is running.
<button
disabled={isSubmitting}
>
{isSubmitting
? "Saving..."
: "Save"}
</button>
isValid
isValid
Represents whether the form currently satisfies its configured validation rules.
Its usefulness depends on the chosen validation mode.
For example:
useForm({
mode: "onChange",
});
can keep validity more continuously updated, but it also runs validation more frequently.
defaultValues
The configured default values are also available through form state.
formState.defaultValues
This can be useful when comparing or displaying original values.
Complete API playground
The following example demonstrates:
useFormregisterhandleSubmitwatchsetValuegetValuessetErrorclearErrorsresetresetFieldtriggercontrolControllererrorsisDirtydirtyFieldstouchedFieldsfieldState.isTouchedisSubmittingisValiddefaultValuesshouldUnregistermode
Create src/App.tsx:
import {
Controller,
SubmitHandler,
useForm,
} from "react-hook-form";
interface ProfileFormValues {
displayName: string;
email: string;
country: string;
state: string;
role: string;
newsletter: boolean;
}
const defaultValues:
ProfileFormValues = {
displayName: "",
email: "",
country: "",
state: "",
role: "",
newsletter: false,
};
interface RolePickerProps {
value: string;
onChange: (
value: string
) => void;
onBlur: () => void;
disabled?: boolean;
}
function RolePicker({
value,
onChange,
onBlur,
disabled,
}: RolePickerProps) {
const roles = [
"student",
"developer",
"designer",
];
return (
<div
className="role-picker"
role="radiogroup"
aria-label="Role"
onBlur={(event) => {
if (
!event.currentTarget
.contains(
event.relatedTarget
)
) {
onBlur();
}
}}
>
{roles.map((role) => {
const selected =
value === role;
return (
<button
key={role}
type="button"
role="radio"
aria-checked={
selected
}
disabled={disabled}
onClick={() => {
onChange(role);
}}
>
{selected
? "✓ "
: ""}
{role}
</button>
);
})}
</div>
);
}
export default function App() {
const {
register,
handleSubmit,
watch,
getValues,
setValue,
setError,
clearErrors,
reset,
resetField,
trigger,
control,
formState: {
errors,
isDirty,
dirtyFields,
touchedFields,
isSubmitting,
isValid,
defaultValues:
activeDefaultValues,
},
} =
useForm<ProfileFormValues>({
defaultValues,
mode: "onBlur",
shouldUnregister: true,
});
const selectedCountry =
watch("country");
const onSubmit:
SubmitHandler<
ProfileFormValues
> = async (values) => {
clearErrors("root.server");
await new Promise(
(resolve) => {
setTimeout(resolve, 1000);
}
);
if (
values.email
.toLowerCase() ===
"existing@example.com"
) {
setError("email", {
type: "server",
message:
"This email is already registered.",
});
return;
}
console.log(
"Submitted profile:",
values
);
reset(values);
};
async function validateIdentity() {
const identityIsValid =
await trigger([
"displayName",
"email",
]);
alert(
identityIsValid
? "Identity fields are valid."
: "Correct the identity fields."
);
}
function previewValues() {
const values =
getValues();
alert(
JSON.stringify(
values,
null,
2
)
);
}
return (
<main className="page">
<form
className="form"
onSubmit={
handleSubmit(onSubmit)
}
noValidate
>
<h1>Edit profile</h1>
<section className="status">
<p>
Form dirty:{" "}
<strong>
{String(isDirty)}
</strong>
</p>
<p>
Form valid:{" "}
<strong>
{String(isValid)}
</strong>
</p>
</section>
<div className="field">
<label htmlFor="displayName">
Display name
</label>
<input
id="displayName"
aria-invalid={Boolean(
errors.displayName
)}
aria-describedby={
errors.displayName
? "displayName-error"
: undefined
}
{...register(
"displayName",
{
required:
"Display name is required.",
minLength: {
value: 2,
message:
"Display name must contain at least 2 characters.",
},
}
)}
/>
{errors.displayName && (
<p
id="displayName-error"
className="error"
>
{
errors.displayName
.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.",
},
onChange: () => {
clearErrors(
"root.server"
);
},
})}
/>
{errors.email && (
<p
id="email-error"
className="error"
>
{errors.email.message}
</p>
)}
</div>
<div className="field">
<label htmlFor="country">
Country
</label>
<select
id="country"
{...register(
"country",
{
required:
"Country is required.",
}
)}
>
<option value="">
Select a country
</option>
<option value="india">
India
</option>
<option value="usa">
United States
</option>
<option value="uk">
United Kingdom
</option>
</select>
{errors.country && (
<p className="error">
{
errors.country
.message
}
</p>
)}
</div>
{selectedCountry ===
"india" && (
<div className="field">
<label htmlFor="state">
State
</label>
<input
id="state"
{...register(
"state",
{
required:
"State is required for India.",
}
)}
/>
{errors.state && (
<p className="error">
{
errors.state
.message
}
</p>
)}
</div>
)}
<Controller
name="role"
control={control}
rules={{
required:
"Select a role.",
}}
render={({
field,
fieldState,
}) => (
<div className="field">
<span>Role</span>
<RolePicker
value={
field.value
}
onChange={
field.onChange
}
onBlur={
field.onBlur
}
disabled={
field.disabled
}
/>
<p>
Role touched:{" "}
{String(
fieldState
.isTouched
)}
</p>
{fieldState.error && (
<p className="error">
{
fieldState.error
.message
}
</p>
)}
</div>
)}
/>
<label>
<input
type="checkbox"
{...register(
"newsletter"
)}
/>
Receive development
updates
</label>
{errors.root?.server && (
<p
className="error"
role="alert"
>
{
errors.root.server
.message
}
</p>
)}
<div className="button-grid">
<button
type="button"
onClick={() => {
setValue(
"displayName",
"Karthik",
{
shouldDirty:
true,
shouldTouch:
true,
shouldValidate:
true,
}
);
}}
>
Use suggested name
</button>
<button
type="button"
onClick={previewValues}
>
Preview values
</button>
<button
type="button"
onClick={() => {
setError(
"root.server",
{
type: "manual",
message:
"This is a demonstration server error.",
}
);
}}
>
Simulate server error
</button>
<button
type="button"
onClick={() => {
clearErrors();
}}
>
Clear errors
</button>
<button
type="button"
onClick={() => {
resetField("email");
}}
>
Reset email
</button>
<button
type="button"
onClick={
validateIdentity
}
>
Validate identity
</button>
<button
type="button"
onClick={() => {
reset();
}}
>
Reset form
</button>
</div>
<button
type="submit"
disabled={isSubmitting}
>
{isSubmitting
? "Saving..."
: "Save profile"}
</button>
<details>
<summary>
Debug form state
</summary>
<pre>
{JSON.stringify(
{
dirtyFields,
touchedFields,
activeDefaultValues,
},
null,
2
)}
</pre>
</details>
</form>
</main>
);
}
Common React Hook Form mistakes
Mistake 1: Using both register() and Controller
Bad:
<Controller
name="email"
control={control}
render={({ field }) => (
<input
{...field}
{...register("email")}
/>
)}
/>
The field is registered twice.
Use either:
register("email")
or:
<Controller
name="email"
control={control}
/>
for that integration.
Mistake 2: Using Controller for every native input
This works, but it gives up some of the simplicity of uncontrolled native registration.
For a standard input, prefer:
<input
{...register("email")}
/>
Use Controller when the component genuinely needs controlled integration.
Mistake 3: Calling watch() for everything
const values = watch();
This is convenient, but it causes the component to care about every form value.
Watch only what the UI requires:
const country =
watch("country");
Mistake 4: Omitting default values
Without a reliable baseline, dirty-state comparisons and reset behavior can become confusing.
Prefer:
useForm({
defaultValues: {
email: "",
password: "",
},
});
Mistake 5: Treating getValues() as reactive
This does not subscribe:
const email =
getValues("email");
The component will not automatically rerender merely because that email changes.
Use:
const email =
watch("email");
when rendering depends on the current value.
Interview questions
What does register() do?
It connects an input to React Hook Form by supplying the field name, event handlers, and ref required for value tracking, validation, touched state, focus management, and submission.
What is the difference between watch() and getValues()?
watch() subscribes to value changes and can cause reactive UI updates.
getValues() reads current values without subscribing to future changes.
When should you use Controller?
Use Controller for controlled third-party or custom components that communicate through value, onChange, and related props instead of exposing a native input ref compatible with register().
What is the difference between reset() and resetField()?
reset() resets the complete form.
resetField() resets one registered field and its selected state.
Why this evolved
React Hook Form provided form-state management, but developers still needed a maintainable way to define complex validation rules. Inline rules worked for small forms, but large forms required reusable, testable validation schemas.
Stage 11: Validation Libraries
Validation can be written manually.
function validateEmail(
email: string
) {
if (!email.trim()) {
return "Email is required.";
}
if (
!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(
email
)
) {
return "Enter a valid email.";
}
return undefined;
}
This is reasonable for one field.
But production validation often includes:
- Nested objects
- Arrays
- Optional fields
- Conditional fields
- Transformations
- Cross-field rules
- Runtime type checking
- Reusable frontend and backend rules
- TypeScript inference
- Structured error paths
TypeScript types do not validate runtime data
Consider:
interface User {
name: string;
age: number;
}
This helps while writing TypeScript.
But an API may return:
{
"name": 123,
"age": "twenty"
}
TypeScript interfaces do not run in the browser or server after compilation.
This assertion:
const user =
responseData as User;
does not validate anything.
It only tells TypeScript:
Trust me. Treat this as User.
A runtime validation library actually checks the value.
Manual validation
Complete manual schema-like validator
interface RegistrationInput {
username: string;
email: string;
password: string;
confirmPassword: string;
}
type RegistrationErrors =
Partial<
Record<
keyof RegistrationInput,
string
>
>;
interface ValidationSuccess {
success: true;
data: RegistrationInput;
}
interface ValidationFailure {
success: false;
errors:
RegistrationErrors;
}
type ValidationResult =
| ValidationSuccess
| ValidationFailure;
function validateRegistration(
input: unknown
): ValidationResult {
if (
typeof input !==
"object" ||
input === null
) {
return {
success: false,
errors: {
username:
"Invalid registration data.",
},
};
}
const candidate =
input as Record<
string,
unknown
>;
const errors:
RegistrationErrors = {};
const username =
typeof candidate.username ===
"string"
? candidate.username.trim()
: "";
const email =
typeof candidate.email ===
"string"
? candidate.email
.trim()
.toLowerCase()
: "";
const password =
typeof candidate.password ===
"string"
? candidate.password
: "";
const confirmPassword =
typeof candidate
.confirmPassword ===
"string"
? candidate
.confirmPassword
: "";
if (!username) {
errors.username =
"Username is required.";
} else if (
username.length < 3
) {
errors.username =
"Username must contain at least 3 characters.";
}
if (!email) {
errors.email =
"Email is required.";
} else if (
!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(
email
)
) {
errors.email =
"Enter a valid email address.";
}
if (password.length < 8) {
errors.password =
"Password must contain at least 8 characters.";
}
if (
confirmPassword !==
password
) {
errors.confirmPassword =
"Passwords do not match.";
}
if (
Object.keys(errors).length >
0
) {
return {
success: false,
errors,
};
}
return {
success: true,
data: {
username,
email,
password,
confirmPassword,
},
};
}
This works.
But we manually implemented:
- Unknown-data checking
- String checking
- Trimming
- Error collection
- Error paths
- Result types
- Cross-field validation
- Data transformation
A schema library standardizes this work.
Yup
Yup is a runtime schema builder that supports validation, parsing, transformations, nested objects, and interdependent rules. Its official repository describes it as an object-schema system for runtime parsing and validation.
Install:
npm install yup
Example:
import * as yup from "yup";
const registrationSchema =
yup
.object({
username:
yup
.string()
.trim()
.required(
"Username is required."
)
.min(
3,
"Username must contain at least 3 characters."
),
email:
yup
.string()
.trim()
.lowercase()
.email(
"Enter a valid email address."
)
.required(
"Email is required."
),
password:
yup
.string()
.required(
"Password is required."
)
.min(
8,
"Password must contain at least 8 characters."
),
confirmPassword:
yup
.string()
.required(
"Confirm your password."
)
.oneOf(
[
yup.ref(
"password"
),
],
"Passwords do not match."
),
})
.required();
type RegistrationInput =
yup.InferType<
typeof registrationSchema
>;
Validate:
try {
const validated =
await registrationSchema
.validate(input, {
abortEarly: false,
});
console.log(validated);
} catch (error) {
if (
error instanceof
yup.ValidationError
) {
console.log(
error.inner
);
}
}
Yup advantages
- Mature ecosystem
- Expressive transformations
- Strong history with Formik
- Nested and conditional rules
- TypeScript inference
- Async validation support
Yup disadvantages
- Some APIs depend heavily on chained transformations
- Input/output behavior can require careful understanding
- Conditional schemas can become difficult to read
- Teams focused on TypeScript-first APIs may prefer alternatives
Zod
Zod is a TypeScript-first runtime validation library with static type inference.
Its schemas can validate values ranging from primitives to complex nested objects, and its current official documentation identifies Zod 4 as stable.
Install:
npm install zod
Example:
import { z } from "zod";
const registrationSchema =
z
.object({
username:
z
.string()
.trim()
.min(
1,
"Username is required."
)
.min(
3,
"Username must contain at least 3 characters."
),
email:
z
.string()
.trim()
.email(
"Enter a valid email address."
)
.transform(
(email) =>
email.toLowerCase()
),
password:
z
.string()
.min(
8,
"Password must contain at least 8 characters."
),
confirmPassword:
z.string(),
})
.refine(
(values) =>
values.password ===
values.confirmPassword,
{
path: [
"confirmPassword",
],
message:
"Passwords do not match.",
}
);
type RegistrationInput =
z.input<
typeof registrationSchema
>;
type RegistrationOutput =
z.output<
typeof registrationSchema
>;
parse()
const validated =
registrationSchema.parse(
input
);
If validation fails, parse() throws a Zod error.
safeParse()
const result =
registrationSchema
.safeParse(input);
if (!result.success) {
console.log(
result.error
);
} else {
console.log(
result.data
);
}
safeParse() returns a discriminated result rather than requiring a try/catch.
Validation result
/ \
success failure
↓ ↓
result.data result.error
Zod advantages
- TypeScript-first API
- Static type inference
- Runtime validation
- Structured error paths
- Transformations
- Nested schemas
- Cross-field validation
- Wide integration ecosystem
- Convenient
safeParse()result - Suitable for client and server boundaries
Zod disadvantages
- Schemas can become large
- Complex transformations may create different input and output types
- Cross-field validation requires deliberate error paths
- Large client bundles may matter in extremely size-sensitive applications
- A schema does not replace business or database validation
Valibot
Valibot is a modular TypeScript schema library designed around type safety, tree shaking, and smaller client bundles. Its official documentation emphasizes that unused schema actions can be removed by bundlers because of its modular API.
Install:
npm install valibot
Example:
import * as v from "valibot";
const registrationSchema =
v.pipe(
v.object({
username:
v.pipe(
v.string(),
v.trim(),
v.nonEmpty(
"Username is required."
),
v.minLength(
3,
"Username must contain at least 3 characters."
)
),
email:
v.pipe(
v.string(),
v.trim(),
v.email(
"Enter a valid email address."
),
v.toLowerCase()
),
password:
v.pipe(
v.string(),
v.minLength(
8,
"Password must contain at least 8 characters."
)
),
confirmPassword:
v.string(),
}),
v.forward(
v.partialCheck(
[
[
"password",
],
[
"confirmPassword",
],
],
(input) =>
input.password ===
input.confirmPassword,
"Passwords do not match."
),
[
"confirmPassword",
]
)
);
type RegistrationInput =
v.InferInput<
typeof registrationSchema
>;
type RegistrationOutput =
v.InferOutput<
typeof registrationSchema
>;
Validate:
const result =
v.safeParse(
registrationSchema,
input
);
if (result.success) {
console.log(
result.output
);
} else {
console.log(
result.issues
);
}
Valibot schemas run at runtime while also supporting inferred TypeScript types.
Valibot advantages
- Modular API
- Strong TypeScript inference
- Tree-shaking-friendly design
- Small client bundles
- Runtime transformations
- Works across browser and server environments
Valibot disadvantages
- More functional and pipeline-oriented syntax
- Smaller historical ecosystem than Yup or Zod
- Teams familiar with chainable APIs may need adjustment
- Advanced cross-field validation can initially look unfamiliar
Vest
Vest takes inspiration from unit-test syntax.
Instead of primarily describing one object schema, you write named validation tests.
Vest’s current documentation describes it as a validation system for workflows that change over time, including focused field validation and protection against outdated asynchronous results.
Install:
npm install vest
Example:
import {
create,
enforce,
test,
} from "vest";
interface RegistrationInput {
username: string;
email: string;
password: string;
confirmPassword: string;
}
const registrationSuite =
create(
(
data:
RegistrationInput
) => {
test(
"username",
"Username is required.",
() => {
enforce(
data.username
).isNotBlank();
}
);
test(
"username",
"Username must contain at least 3 characters.",
() => {
enforce(
data.username
).longerThanOrEquals(
3
);
}
);
test(
"email",
"Enter a valid email address.",
() => {
enforce(
data.email
).matches(
/^[^\s@]+@[^\s@]+\.[^\s@]+$/
);
}
);
test(
"password",
"Password must contain at least 8 characters.",
() => {
enforce(
data.password
).longerThanOrEquals(
8
);
}
);
test(
"confirmPassword",
"Passwords do not match.",
() => {
enforce(
data.confirmPassword
).equals(
data.password
);
}
);
}
);
Run:
const result =
registrationSuite.run(
formData
);
if (
result.hasErrors(
"email"
)
) {
console.log(
result.getError(
"email"
)
);
}
Vest is especially relevant for:
- Multi-step forms
- Asynchronous field validation
- Progressive validation
- Conditional workflows
- Retaining validation results across focused runs
Vest advantages
- Validation reads like tests
- Framework-independent
- Focused field validation
- Stateful validation workflows
- Async race-condition support
- Useful for multi-step and conditional forms
Vest disadvantages
- Different mental model from object schemas
- May be unnecessary for simple payload parsing
- Form state and data state still require architectural decisions
- Less natural when the primary need is a single runtime object schema
Validation-library comparison
| Approach | Main style | Type inference | Transformations | Best fit |
|---|---|---|---|---|
| Manual | Functions and conditions | Manual | Manual | Very small forms |
| Yup | Chainable object schemas | Yes | Strong | Existing Formik/Yup projects |
| Zod | TypeScript-first schemas | Strong | Strong | Full-stack TypeScript applications |
| Valibot | Modular functional pipelines | Strong | Strong | Bundle-sensitive TypeScript applications |
| Vest | Test-like validation suites | Supported | Different focus | Complex interactive validation workflows |
Why Zod became a common choice
Zod fits naturally into TypeScript applications because one schema can provide:
Runtime validation
+
TypeScript type inference
+
Structured error paths
+
Data transformation
Example:
const userSchema =
z.object({
name: z.string(),
age:
z
.number()
.int()
.positive(),
});
type User =
z.infer<
typeof userSchema
>;
Without inference, a developer might write the same structure twice:
interface User {
name: string;
age: number;
}
const userSchema = {
// Duplicate definition
};
Duplicated definitions can drift apart.
With inference:
Schema
├── validates runtime data
└── generates TypeScript type
The official resolver integration can infer values from Zod and several other schema libraries.
Sharing frontend and backend schemas
A monorepo may contain:
apps/
├── web/
└── api/
packages/
└── validation/
└── auth.schema.ts
Shared schema:
import { z } from "zod";
export const registerSchema =
z.object({
username:
z
.string()
.trim()
.min(3),
email:
z
.string()
.trim()
.email(),
password:
z
.string()
.min(8),
});
export type RegisterInput =
z.input<
typeof registerSchema
>;
Frontend:
resolver:
zodResolver(
registerSchema
)
Backend:
const result =
registerSchema.safeParse(
request.body
);
This reduces accidental rule differences.
However, not every rule should be shared.
The backend may also enforce:
- Email uniqueness
- Username uniqueness
- Database constraints
- Authorization
- Rate limits
- Token validity
- Account state
- File scanning
- Organization membership
Complete Zod validation example
Create validation-demo.ts:
import { z } from "zod";
const registrationSchema =
z
.object({
username:
z
.string({
message:
"Username must be text.",
})
.trim()
.min(
1,
"Username is required."
)
.min(
3,
"Username must contain at least 3 characters."
)
.max(
20,
"Username cannot exceed 20 characters."
)
.regex(
/^[A-Za-z0-9_]+$/,
"Use only letters, numbers, and underscores."
),
email:
z
.string({
message:
"Email must be text.",
})
.trim()
.email(
"Enter a valid email address."
)
.transform(
(email) =>
email.toLowerCase()
),
password:
z
.string()
.min(
8,
"Password must contain at least 8 characters."
)
.max(
72,
"Password cannot exceed 72 characters."
),
confirmPassword:
z.string(),
acceptTerms:
z
.boolean()
.refine(
(accepted) =>
accepted,
{
message:
"You must accept the terms.",
}
),
})
.refine(
(values) =>
values.password ===
values.confirmPassword,
{
path: [
"confirmPassword",
],
message:
"Passwords do not match.",
}
);
type RegistrationInput =
z.input<
typeof registrationSchema
>;
type RegistrationOutput =
z.output<
typeof registrationSchema
>;
const input:
RegistrationInput = {
username: " Karthik_2005 ",
email:
" KARTHIK@EXAMPLE.COM ",
password:
"Password123",
confirmPassword:
"Password123",
acceptTerms: true,
};
const result =
registrationSchema
.safeParse(input);
if (!result.success) {
const fieldErrors =
result.error.flatten()
.fieldErrors;
console.log(
"Validation failed:",
fieldErrors
);
} else {
const data:
RegistrationOutput =
result.data;
console.log(
"Validation succeeded:",
data
);
}
Output:
{
username:
"Karthik_2005",
email:
"karthik@example.com",
password:
"Password123",
confirmPassword:
"Password123",
acceptTerms:
true
}
The schema did more than validate.
It also transformed selected values:
" KARTHIK@EXAMPLE.COM "
↓
"karthik@example.com"
Common validation mistakes
Mistake 1: Using TypeScript assertions as validation
const data =
request.body as RegisterInput;
This does not validate the request.
Use:
const result =
registerSchema
.safeParse(
request.body
);
Mistake 2: Sharing inappropriate rules
The frontend can share format rules.
It cannot safely decide:
Email is unique
User is authorized
Token is valid
Session is active
Those require trusted server-side checks.
Mistake 3: Putting every business rule in one schema
Schemas are useful, but a 1,000-line schema can become difficult to maintain.
Separate:
Structural validation
Business services
Database constraints
Authorization
Mistake 4: Ignoring transformed types
A schema can transform:
string input
↓
Date output
or:
string input
↓
number output
In those cases:
z.input<typeof schema>
and:
z.output<typeof schema>
may be different.
Interview questions
Why do we need runtime validation when we already have TypeScript?
TypeScript checks source code during development.
It cannot guarantee that runtime values from forms, APIs, files, databases, or external services match the expected types.
Runtime schemas validate actual values.
What is the difference between parse() and safeParse() in Zod?
parse() returns validated data or throws an error.
safeParse() returns a success-or-failure result object.
Can the same validation schema be used on the frontend and backend?
Yes, structural and format validation can often be shared.
The backend must still enforce trusted business rules such as uniqueness, authorization, token validity, and database constraints.
Why this evolved
Validation libraries made rules reusable and type-safe, but React Hook Form still needed a way to understand their success and error formats. Resolver integrations were created to connect schema validation with form state automatically.
Stage 12: React Hook Form with Zod
React Hook Form manages:
- Inputs
- Errors
- Dirty state
- Touched state
- Submission state
- Reset behavior
Zod manages:
- Validation rules
- Runtime type safety
- Transformations
- Cross-field validation
- Inferred TypeScript types
The resolver connects them.
React Hook Form
↓ values
Zod resolver
↓
Zod schema
/ \
invalid valid
↓ ↓
errors parsed data
↓ ↓
formState onSubmit
Install the dependencies
npm install \
react-hook-form \
zod \
@hookform/resolvers
The resolver package officially supports connecting React Hook Form to Zod and inferring schema output types.
Project structure
src/
├── components/
│ └── FormInput.tsx
│
├── features/
│ └── auth/
│ ├── register.api.ts
│ ├── register.schema.ts
│ └── RegisterForm.tsx
│
├── App.tsx
└── index.css
Each file has one responsibility.
register.schema.ts
→ validation contract
register.api.ts
→ network communication
FormInput.tsx
→ reusable presentation
RegisterForm.tsx
→ form behavior and integration
Step 1: Create the Zod schema
Create:
src/features/auth/register.schema.ts
import { z } from "zod";
export const registerSchema =
z
.object({
username:
z
.string()
.trim()
.min(
1,
"Username is required."
)
.min(
3,
"Username must contain at least 3 characters."
)
.max(
20,
"Username cannot exceed 20 characters."
)
.regex(
/^[A-Za-z0-9_]+$/,
"Use only letters, numbers, and underscores."
),
displayName:
z
.string()
.trim()
.min(
1,
"Display name is required."
)
.min(
2,
"Display name must contain at least 2 characters."
)
.max(
50,
"Display name cannot exceed 50 characters."
),
email:
z
.string()
.trim()
.min(
1,
"Email is required."
)
.email(
"Enter a valid email address."
)
.transform(
(email) =>
email.toLowerCase()
),
password:
z
.string()
.min(
1,
"Password is required."
)
.min(
8,
"Password must contain at least 8 characters."
)
.max(
72,
"Password cannot exceed 72 characters."
)
.regex(
/[A-Z]/,
"Password must contain an uppercase letter."
)
.regex(
/[a-z]/,
"Password must contain a lowercase letter."
)
.regex(
/[0-9]/,
"Password must contain a number."
),
confirmPassword:
z
.string()
.min(
1,
"Confirm your password."
),
acceptTerms:
z
.boolean()
.refine(
(accepted) =>
accepted,
{
message:
"You must accept the terms.",
}
),
})
.refine(
(values) =>
values.password ===
values.confirmPassword,
{
path: [
"confirmPassword",
],
message:
"Passwords do not match.",
}
);
export type RegisterInput =
z.input<
typeof registerSchema
>;
export type RegisterOutput =
z.output<
typeof registerSchema
>;
Why use z.object()?
z.object({
username: z.string(),
email: z.string(),
});
z.object() describes the expected shape of the complete form.
Registration object
├── username: string
├── displayName: string
├── email: string
├── password: string
├── confirmPassword: string
└── acceptTerms: boolean
If a field has the wrong runtime type, the schema rejects it.
Why use refine()?
Field-level methods validate one field.
z.string().min(8)
Password confirmation depends on two fields:
values.password ===
values.confirmPassword
That is a cross-field rule.
.refine(
(values) =>
values.password ===
values.confirmPassword,
{
path: [
"confirmPassword",
],
message:
"Passwords do not match.",
}
)
The path is essential.
Without it, the issue may belong to the complete object.
With:
path: [
"confirmPassword",
]
the error is associated with:
errors.confirmPassword
Step 2: Create the API layer
Create:
src/features/auth/register.api.ts
import type {
RegisterOutput,
} from "./register.schema";
export interface RegisterResponse {
success: true;
message: string;
data: {
user: {
id: string;
username: string;
displayName: string;
email: string;
};
};
}
export interface ApiErrorResponse {
success: false;
message: string;
field?: keyof RegisterOutput;
}
export class ApiError extends Error {
status: number;
data: ApiErrorResponse;
constructor(
status: number,
data: ApiErrorResponse
) {
super(data.message);
this.name = "ApiError";
this.status = status;
this.data = data;
}
}
export async function registerUser(
input: RegisterOutput
): Promise<RegisterResponse> {
// Replace this demonstration with:
//
// const response = await fetch(
// "/api/auth/register",
// {
// method: "POST",
// headers: {
// "Content-Type":
// "application/json",
// },
// body: JSON.stringify(input),
// }
// );
await new Promise(
(resolve) => {
setTimeout(resolve, 1000);
}
);
if (
input.email ===
"existing@example.com"
) {
throw new ApiError(
409,
{
success: false,
field: "email",
message:
"An account with this email already exists.",
}
);
}
if (
input.username
.toLowerCase() ===
"admin"
) {
throw new ApiError(
409,
{
success: false,
field: "username",
message:
"This username is not available.",
}
);
}
return {
success: true,
message:
"Registration successful. Check your email to verify your account.",
data: {
user: {
id:
crypto.randomUUID(),
username:
input.username,
displayName:
input.displayName,
email:
input.email,
},
},
};
}
This mock allows the frontend example to run without a backend.
Later, the mock can be replaced with a real Fetch or Axios request.
Step 3: Create a reusable input
Create:
src/components/FormInput.tsx
import {
forwardRef,
InputHTMLAttributes,
} from "react";
interface FormInputProps
extends InputHTMLAttributes<HTMLInputElement> {
label: string;
error?: string;
}
const FormInput =
forwardRef<
HTMLInputElement,
FormInputProps
>(function FormInput(
{
id,
label,
error,
className = "",
...inputProps
},
ref
) {
if (!id) {
throw new Error(
"FormInput requires an id."
);
}
const errorId =
`${id}-error`;
return (
<div className="field">
<label htmlFor={id}>
{label}
</label>
<input
id={id}
ref={ref}
className={
className
}
aria-invalid={Boolean(
error
)}
aria-describedby={
error
? errorId
: undefined
}
{...inputProps}
/>
{error && (
<p
id={errorId}
className="error"
>
{error}
</p>
)}
</div>
);
});
export default FormInput;
Why forwardRef() is required
React Hook Form supplies a ref through:
register("email")
Our custom component must pass that ref to the real input.
React Hook Form ref
↓
FormInput component
↓
forwardRef
↓
native <input>
Without forwarding:
<input ref={ref} />
the registration ref would stop at the custom component boundary.
Step 4: Create the registration form
Create:
src/features/auth/RegisterForm.tsx
import {
SubmitHandler,
useForm,
} from "react-hook-form";
import {
zodResolver,
} from "@hookform/resolvers/zod";
import FormInput from "../../components/FormInput";
import {
ApiError,
registerUser,
} from "./register.api";
import {
RegisterInput,
RegisterOutput,
registerSchema,
} from "./register.schema";
const defaultValues:
RegisterInput = {
username: "",
displayName: "",
email: "",
password: "",
confirmPassword: "",
acceptTerms: false,
};
export default function RegisterForm() {
const {
register,
handleSubmit,
setError,
clearErrors,
reset,
formState: {
errors,
isDirty,
dirtyFields,
touchedFields,
isSubmitting,
isSubmitSuccessful,
},
} =
useForm<
RegisterInput,
unknown,
RegisterOutput
>({
defaultValues,
resolver:
zodResolver(
registerSchema
),
mode: "onBlur",
});
const onSubmit:
SubmitHandler<
RegisterOutput
> = async (values) => {
clearErrors("root.server");
try {
const response =
await registerUser(
values
);
console.log(
"Registration response:",
response
);
reset();
alert(
response.message
);
} catch (error) {
if (
error instanceof
ApiError
) {
const {
field,
message,
} = error.data;
if (
field &&
field in
defaultValues
) {
setError(
field as keyof RegisterInput,
{
type: "server",
message,
},
{
shouldFocus:
true,
}
);
return;
}
setError(
"root.server",
{
type: "server",
message,
}
);
return;
}
setError(
"root.server",
{
type: "unknown",
message:
"Unable to create your account. Try again.",
}
);
}
};
return (
<form
className="form"
onSubmit={
handleSubmit(onSubmit)
}
noValidate
>
<header>
<h1>Create an account</h1>
<p>
Learn modern form
validation with React
Hook Form and Zod.
</p>
</header>
<FormInput
id="username"
label="Username"
type="text"
autoComplete="username"
error={
errors.username
?.message
}
{...register(
"username"
)}
/>
<FormInput
id="displayName"
label="Display name"
type="text"
autoComplete="name"
error={
errors.displayName
?.message
}
{...register(
"displayName"
)}
/>
<FormInput
id="email"
label="Email"
type="email"
autoComplete="email"
error={
errors.email
?.message
}
{...register(
"email",
{
onChange: () => {
clearErrors(
"root.server"
);
},
}
)}
/>
<FormInput
id="password"
label="Password"
type="password"
autoComplete="new-password"
error={
errors.password
?.message
}
{...register(
"password"
)}
/>
<FormInput
id="confirmPassword"
label="Confirm password"
type="password"
autoComplete="new-password"
error={
errors
.confirmPassword
?.message
}
{...register(
"confirmPassword"
)}
/>
<div className="field">
<label className="checkbox">
<input
type="checkbox"
aria-invalid={Boolean(
errors.acceptTerms
)}
aria-describedby={
errors.acceptTerms
? "acceptTerms-error"
: undefined
}
{...register(
"acceptTerms"
)}
/>
<span>
I accept the terms
and privacy policy.
</span>
</label>
{errors.acceptTerms && (
<p
id="acceptTerms-error"
className="error"
>
{
errors.acceptTerms
.message
}
</p>
)}
</div>
{errors.root?.server && (
<p
className="error alert"
role="alert"
>
{
errors.root.server
.message
}
</p>
)}
{isSubmitSuccessful && (
<p
className="success"
role="status"
>
The last submission
completed successfully.
</p>
)}
<button
type="submit"
disabled={isSubmitting}
>
{isSubmitting
? "Creating account..."
: "Create account"}
</button>
<details>
<summary>
Development state
</summary>
<pre>
{JSON.stringify(
{
isDirty,
dirtyFields,
touchedFields,
},
null,
2
)}
</pre>
</details>
</form>
);
}
Step 5: Render the form
Create src/App.tsx:
import RegisterForm from "./features/auth/RegisterForm";
export default function App() {
return (
<main className="page">
<RegisterForm />
</main>
);
}
Step 6: Add styles
Create or update src/index.css:
* {
box-sizing: border-box;
}
body {
margin: 0;
background: #f4f4f5;
color: #18181b;
font-family:
Inter,
Arial,
sans-serif;
}
button,
input {
font: inherit;
}
.page {
min-height: 100vh;
display: grid;
place-items: center;
padding: 24px;
}
.form {
width: min(100%, 500px);
display: grid;
gap: 18px;
padding: 28px;
background: white;
border-radius: 14px;
box-shadow:
0 10px 30px
rgb(0 0 0 / 8%);
}
.form header {
display: grid;
gap: 6px;
}
.form h1,
.form p {
margin: 0;
}
.field {
display: grid;
gap: 6px;
}
input {
width: 100%;
padding: 10px 12px;
border: 1px solid #71717a;
border-radius: 6px;
}
input:focus {
outline:
3px solid
rgb(59 130 246 / 25%);
border-color: #2563eb;
}
input[aria-invalid="true"] {
border-color: #b91c1c;
}
.checkbox {
display: flex;
align-items: flex-start;
gap: 10px;
}
.checkbox input {
width: auto;
margin-top: 4px;
}
button {
padding: 11px 16px;
border: 0;
border-radius: 6px;
background: #18181b;
color: white;
cursor: pointer;
}
button:disabled {
cursor: not-allowed;
opacity: 0.65;
}
.error {
color: #b91c1c;
font-size: 14px;
}
.success {
color: #15803d;
}
.alert {
padding: 10px;
border:
1px solid
#b91c1c;
border-radius: 6px;
background: #fef2f2;
}
details {
padding-top: 8px;
}
pre {
max-width: 100%;
overflow: auto;
padding: 12px;
background: #f4f4f5;
border-radius: 6px;
font-size: 12px;
}
Understanding the useForm generic types
We used:
useForm<
RegisterInput,
unknown,
RegisterOutput
>()
The three generic positions represent:
useForm<
Input,
Context,
Output
>
The resolver documentation shows this input/context/output form for schemas that transform data.
In our schema:
type RegisterInput =
z.input<
typeof registerSchema
>;
describes values before schema parsing.
type RegisterOutput =
z.output<
typeof registerSchema
>;
describes values after parsing and transformations.
Our email transformation changes:
KARTHIK@EXAMPLE.COM
↓
karthik@example.com
The TypeScript type remains a string, but the semantic value is normalized.
For schemas that convert strings into dates or numbers, the input and output TypeScript types may also differ.
What does zodResolver() do?
We configure:
resolver:
zodResolver(
registerSchema
)
When the form validates, the resolver conceptually performs:
const result =
registerSchema
.safeParse(values);
If successful:
{
values: result.data,
errors: {}
}
If unsuccessful, it maps Zod issues into field errors:
{
values: {},
errors: {
email: {
type:
"invalid_format",
message:
"Enter a valid email address."
}
}
}
The exact internal representation should be treated as library implementation detail, but this is the important public flow.
Complete validation data flow
User types into email input
↓
register("email") tracks field
↓
User blurs field or submits form
↓
React Hook Form collects values
↓
zodResolver receives values
↓
registerSchema validates values
/ \
Invalid Valid
↓ ↓
Zod returns issues Zod returns parsed data
↓ ↓
Resolver maps issues handleSubmit calls
to RHF errors onSubmit(parsedData)
↓ ↓
errors.email API function runs
↓
Error rendered under email
How one error reaches only one input
Suppose Zod produces an issue at:
path: [
"email",
]
The resolver converts it into:
errors.email
Our component passes:
error={
errors.email?.message
}
only to the email input:
<FormInput
id="email"
error={
errors.email?.message
}
/>
The username input reads:
errors.username
The password input reads:
errors.password
Therefore:
errors.email
↓
Email FormInput
↓
email-error paragraph
It does not automatically appear under every input.
Each component explicitly reads the error path belonging to its own field.
Cross-field error flow
The schema contains:
.refine(
(values) =>
values.password ===
values.confirmPassword,
{
path: [
"confirmPassword",
],
message:
"Passwords do not match.",
}
)
The path produces:
errors.confirmPassword
Then:
<FormInput
error={
errors.confirmPassword
?.message
}
/>
displays it only under the confirmation field.
Cross-field comparison fails
↓
Zod issue path:
confirmPassword
↓
errors.confirmPassword
↓
Confirmation input error
Frontend validation is not security
The React form can be bypassed.
An attacker can directly send:
POST /api/auth/register
Content-Type: application/json
{
"username": "",
"email": "invalid",
"password": "1"
}
Therefore, the backend must also validate the body.
const result =
registerSchema
.safeParse(
request.body
);
if (!result.success) {
return response
.status(400)
.json({
success: false,
message:
"Validation failed.",
errors:
result.error
.flatten()
.fieldErrors,
});
}
The frontend provides fast feedback.
The backend enforces correctness.
Accessibility review
Our reusable input connects:
<label htmlFor={id}>
to:
<input id={id} />
When an error exists:
aria-invalid={true}
and:
aria-describedby={
`${id}-error`
}
connect the field to:
<p id={`${id}-error`}>
The relationship is:
Input
aria-describedby="email-error"
↓
Error paragraph
id="email-error"
The form also uses:
role="alert"
for a root server error.
Performance notes
Using Zod does not mean validation is free.
Validation still performs work.
Be deliberate about:
mode: "onChange"
for large schemas because it can validate frequently.
A common balanced configuration is:
mode: "onBlur"
or the default submit-first approach.
For expensive asynchronous checks such as username availability:
- Debounce requests
- Cancel outdated requests
- Avoid calling the backend on every raw keystroke
- Validate format locally first
- Perform the authoritative check on submission
Common React Hook Form + Zod mistakes
Mistake 1: Defining the type separately
Avoid duplicating:
interface RegisterInput {
username: string;
email: string;
}
and:
const registerSchema =
z.object({
username: z.string(),
email: z.string(),
});
Prefer inference:
type RegisterInput =
z.input<
typeof registerSchema
>;
Mistake 2: Forgetting the resolver
Defining a schema does not automatically connect it to the form.
This:
const schema =
z.object({
email: z.string(),
});
does nothing by itself.
Connect it:
useForm({
resolver:
zodResolver(schema),
});
Mistake 3: Adding duplicate rules
Avoid unnecessarily validating the same field in both:
register("email", {
required:
"Email is required.",
})
and:
z.string().min(
1,
"Email is required."
)
When using a resolver, keep the primary validation contract in the schema unless a field-specific registration rule has a deliberate purpose.
Mistake 4: Forgetting the cross-field path
Bad:
.refine(
passwordsMatch,
{
message:
"Passwords do not match.",
}
)
The error may be treated as an object-level issue.
Better:
.refine(
passwordsMatch,
{
path: [
"confirmPassword",
],
message:
"Passwords do not match.",
}
)
Mistake 5: Trimming passwords automatically
This may be dangerous:
password:
z
.string()
.trim()
A user’s password may intentionally contain leading or trailing spaces.
Normalize usernames and emails deliberately.
Do not silently transform passwords unless the product explicitly defines that behavior.
Mistake 6: Expecting Zod to check the database
Zod can validate:
Email has a valid format
It cannot independently know:
Email already exists
That requires a database query.
The backend returns the result, and React Hook Form can inject it using:
setError("email", {
type: "server",
message:
"Email already exists.",
});
Senior engineer tips
Keep schemas outside components
Better:
register.schema.ts
RegisterForm.tsx
Avoid redefining a large schema every time the component function runs.
Separate input and output types when transformations exist
type Input =
z.input<typeof schema>;
type Output =
z.output<typeof schema>;
This becomes important when converting:
"21"
↓
21
or:
"2026-08-24"
↓
Date object
Use schemas at trust boundaries
Good validation boundaries include:
Form submission
API request body
Environment variables
External API response
File contents
Queue messages
Database JSON fields
Keep the API layer separate
Avoid placing every Fetch detail inside the form component.
Better:
RegisterForm
↓
registerUser()
↓
HTTP request
The form handles interface behavior.
The API function handles communication.
Interview questions
What is a resolver in React Hook Form?
A resolver adapts the result of an external validation library into React Hook Form’s expected values-and-errors format.
Why combine React Hook Form and Zod?
React Hook Form manages interactive form state and field registration.
Zod manages runtime data validation, transformations, cross-field rules, and inferred TypeScript types.
How does a Zod error appear in errors.email?
Zod produces an issue whose path contains email.
The Zod resolver maps that issue to React Hook Form’s nested error object under the same field path.
What is the difference between z.input and z.output?
z.input represents the value accepted before schema parsing and transformations.
z.output represents the validated value returned after parsing and transformations.
Should the backend validate again when the frontend uses Zod?
Yes.
Frontend code can be bypassed. The backend must validate every untrusted request independently.
Part 3 summary
React Hook Form changed the form architecture from:
Every keystroke
↓
Parent React state
↓
Complete form component render
toward:
Native input stores value
↓
register connects field
↓
Form control tracks state
↓
Subscribed UI updates
Its major APIs can be organized as:
Create
└── useForm
Connect
├── register
└── Controller
Read
├── watch
└── getValues
Write
└── setValue
Validate
├── handleSubmit
├── trigger
├── setError
└── clearErrors
Reset
├── reset
└── resetField
Observe
└── formState
Validation libraries then moved rules from scattered conditions into reusable schemas.
Manual conditions
↓
Schema validation
↓
Runtime safety
↓
Type inference
↓
Shared validation contracts
Finally, React Hook Form and Zod combined their responsibilities:
React Hook Form
→ form behavior
Zod
→ validation contract
zodResolver
→ connection between them
The next part will cover:
Stage 13
Server-side validation with Express
Stage 14
Mapping backend errors with setError()
Stage 15
Accessible production forms
Stage 16
Form performance and render comparisons
Top comments (0)