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"
);
Enter fullscreen mode Exit fullscreen mode
React allowed us to describe the interface from state:
<input
aria-invalid={Boolean(errors.email)}
/>
{errors.email && (
<p>{errors.email}</p>
)}
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
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);
}}
/>
Enter fullscreen mode Exit fullscreen mode
Every keystroke produces a state update:
User types
↓
onChange runs
↓
setEmail runs
↓
Component renders again
↓
Input receives the new value
Enter fullscreen mode Exit fullscreen mode
React Hook Form prefers native, uncontrolled inputs when possible.
<input
{...register("email")}
/>
Enter fullscreen mode Exit fullscreen mode
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);
}}
/>
Enter fullscreen mode Exit fullscreen mode
React owns the value.
React state
↓
input value
Enter fullscreen mode Exit fullscreen mode
The input cannot independently keep a different value because React continuously supplies it.
Uncontrolled input
<input
name="email"
defaultValue=""
/>
Enter fullscreen mode Exit fullscreen mode
The browser owns the current value.
DOM input element
↓
current value
Enter fullscreen mode Exit fullscreen mode
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>
);
}
Enter fullscreen mode Exit fullscreen mode
Notice what is missing:
value={email}
onChange={handleChange}
Enter fullscreen mode Exit fullscreen mode
The browser stores the entered value.
On submission, we access the element through:
emailRef.current
Enter fullscreen mode Exit fullscreen mode
and read:
emailRef.current.value
Enter fullscreen mode Exit fullscreen mode
What is a ref?
A ref gives JavaScript access to an element or another persistent value.
const emailRef =
useRef<HTMLInputElement>(null);
Enter fullscreen mode Exit fullscreen mode
After React connects the ref to the input:
<input ref={emailRef} />
Enter fullscreen mode Exit fullscreen mode
the ref may contain the actual DOM element:
emailRef.current
↓
HTMLInputElement
Enter fullscreen mode Exit fullscreen mode
We can then access browser properties:
emailRef.current?.value;
emailRef.current?.focus();
emailRef.current?.disabled;
emailRef.current?.files;
Enter fullscreen mode Exit fullscreen mode
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);
Enter fullscreen mode Exit fullscreen mode
Conceptually, the returned object resembles:
{
name: "email",
onChange: function,
onBlur: function,
ref: function
}
Enter fullscreen mode Exit fullscreen mode
When we write:
<input
{...register("email")}
/>
Enter fullscreen mode Exit fullscreen mode
the spread operator applies those properties to the input.
Conceptually, it becomes:
<input
name="email"
onChange={registeredOnChange}
onBlur={registeredOnBlur}
ref={registeredRef}
/>
Enter fullscreen mode Exit fullscreen mode
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")
Enter fullscreen mode Exit fullscreen mode
registers a field under the key:
email
Enter fullscreen mode Exit fullscreen mode
The submitted object becomes:
{
email: "[email protected]"
}
Enter fullscreen mode Exit fullscreen mode
A nested field name:
register("address.city")
Enter fullscreen mode Exit fullscreen mode
can produce:
{
address: {
city: "Kadapa"
}
}
Enter fullscreen mode Exit fullscreen mode
An array-style field:
register(
"experiences.0.company"
)
Enter fullscreen mode Exit fullscreen mode
can produce:
{
experiences: [
{
company: "Example Company"
}
]
}
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
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();
Enter fullscreen mode Exit fullscreen mode
The component is interested in errors.
If it also reads:
const {
formState: {
errors,
isDirty,
isSubmitting,
},
} = useForm();
Enter fullscreen mode Exit fullscreen mode
it is interested in three pieces of form state.
Conceptually:
Component subscribes to:
├── errors
├── isDirty
└── isSubmitting
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
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>
);
}
Enter fullscreen mode Exit fullscreen mode
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;
}
Enter fullscreen mode Exit fullscreen mode
Controlled React versus React Hook Form
Controlled form
const [email, setEmail] =
useState("");
<input
value={email}
onChange={(event) => {
setEmail(event.target.value);
}}
/>
Enter fullscreen mode Exit fullscreen mode
React Hook Form
<input
{...register("email")}
/>
Enter fullscreen mode Exit fullscreen mode
Comparison
Concern Controlled React React Hook Form Current native input value React state Usually the DOM input Change handler Manually written Supplied byregister()
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}
/>
Enter fullscreen mode Exit fullscreen mode
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
);
},
})}
/>
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
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>();
Enter fullscreen mode Exit fullscreen mode
Most applications destructure the required methods:
const {
register,
handleSubmit,
formState: {
errors,
},
} =
useForm<RegistrationValues>();
Enter fullscreen mode Exit fullscreen mode
Important useForm() options
useForm<RegistrationValues>({
defaultValues: {
username: "",
email: "",
password: "",
},
mode: "onBlur",
shouldUnregister: false,
});
Enter fullscreen mode Exit fullscreen mode
Important options include:
defaultValuesmodereValidateModeresolvershouldUnregistercriteriaModecontextdisabled
2. defaultValues
const defaultValues = {
username: "",
email: "",
password: "",
};
useForm({
defaultValues,
});
Enter fullscreen mode Exit fullscreen mode
Default values serve as the baseline for:
- Initial field values
- Reset behavior
- Dirty comparison
Default email:
""
Current email:
"[email protected]"
isDirty:
true
Enter fullscreen mode Exit fullscreen mode
If the current value returns to the default:
Current email:
""
isDirty:
false
Enter fullscreen mode Exit fullscreen mode
Use consistent values.
For text inputs, prefer:
email: ""
Enter fullscreen mode Exit fullscreen mode
instead of:
email: undefined
Enter fullscreen mode Exit fullscreen mode
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();
},
});
Enter fullscreen mode Exit fullscreen mode
For externally loaded data, reset() is also commonly used after the data arrives.
3. mode
mode controls when initial validation runs.
useForm({
mode: "onSubmit",
});
Enter fullscreen mode Exit fullscreen mode
Common modes include:
Mode Validation timingonSubmit
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",
});
Enter fullscreen mode Exit fullscreen mode
This provides a balanced experience:
User types
↓
No immediate interruption
↓
User leaves field
↓
Validate field
Enter fullscreen mode Exit fullscreen mode
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"
)}
/>
)}
Enter fullscreen mode Exit fullscreen mode
When companyName disappears, should its value remain in the form?
With the default preservation-oriented behavior:
shouldUnregister: false
Enter fullscreen mode Exit fullscreen mode
an unmounted field can remain represented in form data.
With:
shouldUnregister: true
Enter fullscreen mode Exit fullscreen mode
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")}
/>
Enter fullscreen mode Exit fullscreen mode
It can also accept validation rules:
<input
{...register("email", {
required:
"Email is required.",
pattern: {
value:
/^[^\s@]+@[^\s@]+\.[^\s@]+$/,
message:
"Enter a valid email address.",
},
})}
/>
Enter fullscreen mode Exit fullscreen mode
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."
);
},
});
Enter fullscreen mode Exit fullscreen mode
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."
);
},
},
});
Enter fullscreen mode Exit fullscreen mode
A validation function can return:
true
Enter fullscreen mode Exit fullscreen mode
when valid, or:
an error message
Enter fullscreen mode Exit fullscreen mode
when invalid.
6. handleSubmit()
handleSubmit() coordinates validation and submission.
<form
onSubmit={
handleSubmit(onSubmit)
}
>
Enter fullscreen mode Exit fullscreen mode
The success callback receives validated values:
const onSubmit = (
values: RegistrationValues
) => {
console.log(values);
};
Enter fullscreen mode Exit fullscreen mode
You can also provide an invalid callback:
const onInvalid = (
errors: FieldErrors<
RegistrationValues
>
) => {
console.log(
"Validation failed:",
errors
);
};
<form
onSubmit={
handleSubmit(
onSubmit,
onInvalid
)
}
/>
Enter fullscreen mode Exit fullscreen mode
The process is:
Native submit event
↓
handleSubmit
↓
Run validation
/ \
Invalid Valid
↓ ↓
onInvalid onSubmit
Enter fullscreen mode Exit fullscreen mode
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");
Enter fullscreen mode Exit fullscreen mode
You can use the value for conditional rendering:
{country === "india" && (
<input
{...register("state")}
/>
)}
Enter fullscreen mode Exit fullscreen mode
Watch multiple fields:
const [
password,
confirmPassword,
] = watch([
"password",
"confirmPassword",
]);
Enter fullscreen mode Exit fullscreen mode
Watch the entire form:
const values = watch();
Enter fullscreen mode Exit fullscreen mode
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");
Enter fullscreen mode Exit fullscreen mode
watch() subscribes to changes.
const email =
getValues("email");
Enter fullscreen mode Exit fullscreen mode
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()
Enter fullscreen mode Exit fullscreen mode
when the UI must react to changes.
Use:
getValues()
Enter fullscreen mode Exit fullscreen mode
when you only need a value at a particular moment.
8. getValues()
Read every field:
const values =
getValues();
Enter fullscreen mode Exit fullscreen mode
Read one field:
const email =
getValues("email");
Enter fullscreen mode Exit fullscreen mode
Read several fields:
const [
email,
username,
] = getValues([
"email",
"username",
]);
Enter fullscreen mode Exit fullscreen mode
Example:
function handlePreview() {
const values =
getValues();
console.log(
"Preview:",
values
);
}
Enter fullscreen mode Exit fullscreen mode
<button
type="button"
onClick={handlePreview}
>
Preview current data
</button>
Enter fullscreen mode Exit fullscreen mode
9. setValue()
setValue() updates a field programmatically.
setValue(
"email",
"[email protected]"
);
Enter fullscreen mode Exit fullscreen mode
Options can update related form state:
setValue(
"email",
"[email protected]",
{
shouldValidate: true,
shouldDirty: true,
shouldTouch: true,
}
);
Enter fullscreen mode Exit fullscreen mode
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,
}
);
}
Enter fullscreen mode Exit fullscreen mode
OCR document extraction
setValue(
"fullName",
extractedDocument.name,
{
shouldDirty: true,
shouldValidate: true,
}
);
Enter fullscreen mode Exit fullscreen mode
Choosing a suggested username
setValue(
"username",
suggestedUsername,
{
shouldDirty: true,
shouldValidate: true,
}
);
Enter fullscreen mode Exit fullscreen mode
10. setError()
setError() manually inserts an error.
setError("email", {
type: "server",
message:
"Email already exists.",
});
Enter fullscreen mode Exit fullscreen mode
The result becomes available through:
errors.email
Enter fullscreen mode Exit fullscreen mode
You can then render:
{errors.email && (
<p>
{errors.email.message}
</p>
)}
Enter fullscreen mode Exit fullscreen mode
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.",
});
Enter fullscreen mode Exit fullscreen mode
Display it:
{errors.root?.server && (
<p role="alert">
{
errors.root.server
.message
}
</p>
)}
Enter fullscreen mode Exit fullscreen mode
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");
Enter fullscreen mode Exit fullscreen mode
Clear multiple errors:
clearErrors([
"email",
"username",
]);
Enter fullscreen mode Exit fullscreen mode
Clear all errors:
clearErrors();
Enter fullscreen mode Exit fullscreen mode
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"
);
},
})}
/>
Enter fullscreen mode Exit fullscreen mode
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();
Enter fullscreen mode Exit fullscreen mode
Reset with new values:
reset({
username: "karthik",
email:
"[email protected]",
password: "",
});
Enter fullscreen mode Exit fullscreen mode
This can be useful after fetching existing data:
useEffect(() => {
if (user) {
reset({
username:
user.username,
email:
user.email,
password: "",
});
}
}, [user, reset]);
Enter fullscreen mode Exit fullscreen mode
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,
}
);
Enter fullscreen mode Exit fullscreen mode
Use preservation options carefully.
After a successful registration, the common behavior is:
reset();
Enter fullscreen mode Exit fullscreen mode
After an API refresh, you may intentionally preserve dirty user edits.
13. resetField()
Reset only one field:
resetField("email");
Enter fullscreen mode Exit fullscreen mode
Reset to a new default value:
resetField("email", {
defaultValue:
"[email protected]",
});
Enter fullscreen mode Exit fullscreen mode
Optionally preserve state:
resetField("email", {
keepError: true,
keepDirty: true,
keepTouched: true,
});
Enter fullscreen mode Exit fullscreen mode
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();
Enter fullscreen mode Exit fullscreen mode
Validate one field:
const emailIsValid =
await trigger("email");
Enter fullscreen mode Exit fullscreen mode
Validate several fields:
const credentialsAreValid =
await trigger([
"email",
"password",
]);
Enter fullscreen mode Exit fullscreen mode
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);
}
Enter fullscreen mode Exit fullscreen mode
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();
Enter fullscreen mode Exit fullscreen mode
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}
/>
Enter fullscreen mode Exit fullscreen mode
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
}
/>
)}
/>
Enter fullscreen mode Exit fullscreen mode
Controller supplies:
field
├── name
├── value
├── onChange
├── onBlur
├── ref
└── disabled
fieldState
├── error
├── invalid
├── isDirty
└── isTouched
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
Instead, React Hook Form provides:
formState.touchedFields
Enter fullscreen mode Exit fullscreen mode
for the collection of touched fields.
For one controlled field, Controller provides:
fieldState.isTouched
Enter fullscreen mode Exit fullscreen mode
Conceptually:
Whole form:
touchedFields.email
One Controller field:
fieldState.isTouched
Enter fullscreen mode Exit fullscreen mode
17. resolver
A resolver connects React Hook Form to an external validation library.
useForm({
resolver:
zodResolver(schema),
});
Enter fullscreen mode Exit fullscreen mode
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();
Enter fullscreen mode Exit fullscreen mode
Common properties include:
const {
errors,
isDirty,
dirtyFields,
touchedFields,
isSubmitting,
isValid,
isSubmitted,
isSubmitSuccessful,
submitCount,
defaultValues,
} = formState;
Enter fullscreen mode Exit fullscreen mode
React Hook Form documents formState as the source of current errors, dirty status, touched fields, submission status, and validity.
errors
errors.email
Enter fullscreen mode Exit fullscreen mode
Possible shape:
{
type: "required",
message:
"Email is required.",
ref: HTMLInputElement
}
Enter fullscreen mode Exit fullscreen mode
Render:
{errors.email && (
<p>
{errors.email.message}
</p>
)}
Enter fullscreen mode Exit fullscreen mode
isDirty
isDirty
Enter fullscreen mode Exit fullscreen mode
Indicates whether the form currently differs from its default values.
<button
disabled={!isDirty}
>
Save changes
</button>
Enter fullscreen mode Exit fullscreen mode
dirtyFields
dirtyFields
Enter fullscreen mode Exit fullscreen mode
Identifies individual changed fields.
Example:
{
email: true,
address: {
city: true
}
}
Enter fullscreen mode Exit fullscreen mode
Use it for:
- Autosaving selected fields
- Showing changed-field indicators
- Building partial update requests
- Warning about unsaved changes
touchedFields
touchedFields
Enter fullscreen mode Exit fullscreen mode
Tracks fields that have been interacted with according to the form’s event lifecycle.
Example:
{
email: true,
password: true
}
Enter fullscreen mode Exit fullscreen mode
isSubmitting
isSubmitting
Enter fullscreen mode Exit fullscreen mode
Becomes true while an asynchronous submit callback is running.
<button
disabled={isSubmitting}
>
{isSubmitting
? "Saving..."
: "Save"}
</button>
Enter fullscreen mode Exit fullscreen mode
isValid
isValid
Enter fullscreen mode Exit fullscreen mode
Represents whether the form currently satisfies its configured validation rules.
Its usefulness depends on the chosen validation mode.
For example:
useForm({
mode: "onChange",
});
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
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() ===
"[email protected]"
) {
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>
);
}
Enter fullscreen mode Exit fullscreen mode
Common React Hook Form mistakes
Mistake 1: Using both register() and Controller
Bad:
<Controller
name="email"
control={control}
render={({ field }) => (
<input
{...field}
{...register("email")}
/>
)}
/>
Enter fullscreen mode Exit fullscreen mode
The field is registered twice.
Use either:
register("email")
Enter fullscreen mode Exit fullscreen mode
or:
<Controller
name="email"
control={control}
/>
Enter fullscreen mode Exit fullscreen mode
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")}
/>
Enter fullscreen mode Exit fullscreen mode
Use Controller when the component genuinely needs controlled integration.
Mistake 3: Calling watch() for everything
const values = watch();
Enter fullscreen mode Exit fullscreen mode
This is convenient, but it causes the component to care about every form value.
Watch only what the UI requires:
const country =
watch("country");
Enter fullscreen mode Exit fullscreen mode
Mistake 4: Omitting default values
Without a reliable baseline, dirty-state comparisons and reset behavior can become confusing.
Prefer:
useForm({
defaultValues: {
email: "",
password: "",
},
});
Enter fullscreen mode Exit fullscreen mode
Mistake 5: Treating getValues() as reactive
This does not subscribe:
const email =
getValues("email");
Enter fullscreen mode Exit fullscreen mode
The component will not automatically rerender merely because that email changes.
Use:
const email =
watch("email");
Enter fullscreen mode Exit fullscreen mode
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;
}
Enter fullscreen mode Exit fullscreen mode
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;
}
Enter fullscreen mode Exit fullscreen mode
This helps while writing TypeScript.
But an API may return:
{
"name": 123,
"age": "twenty"
}
Enter fullscreen mode Exit fullscreen mode
TypeScript interfaces do not run in the browser or server after compilation.
This assertion:
const user =
responseData as User;
Enter fullscreen mode Exit fullscreen mode
does not validate anything.
It only tells TypeScript:
Trust me. Treat this as User.
Enter fullscreen mode Exit fullscreen mode
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,
},
};
}
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
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
>;
Enter fullscreen mode Exit fullscreen mode
Validate:
try {
const validated =
await registrationSchema
.validate(input, {
abortEarly: false,
});
console.log(validated);
} catch (error) {
if (
error instanceof
yup.ValidationError
) {
console.log(
error.inner
);
}
}
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
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
>;
Enter fullscreen mode Exit fullscreen mode
parse()
const validated =
registrationSchema.parse(
input
);
Enter fullscreen mode Exit fullscreen mode
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
);
}
Enter fullscreen mode Exit fullscreen mode
safeParse() returns a discriminated result rather than requiring a try/catch.
Validation result
/ \
success failure
↓ ↓
result.data result.error
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
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
>;
Enter fullscreen mode Exit fullscreen mode
Validate:
const result =
v.safeParse(
registrationSchema,
input
);
if (result.success) {
console.log(
result.output
);
} else {
console.log(
result.issues
);
}
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
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
);
}
);
}
);
Enter fullscreen mode Exit fullscreen mode
Run:
const result =
registrationSuite.run(
formData
);
if (
result.hasErrors(
"email"
)
) {
console.log(
result.getError(
"email"
)
);
}
Enter fullscreen mode Exit fullscreen mode
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 workflowsWhy 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
Enter fullscreen mode Exit fullscreen mode
Example:
const userSchema =
z.object({
name: z.string(),
age:
z
.number()
.int()
.positive(),
});
type User =
z.infer<
typeof userSchema
>;
Enter fullscreen mode Exit fullscreen mode
Without inference, a developer might write the same structure twice:
interface User {
name: string;
age: number;
}
const userSchema = {
// Duplicate definition
};
Enter fullscreen mode Exit fullscreen mode
Duplicated definitions can drift apart.
With inference:
Schema
├── validates runtime data
└── generates TypeScript type
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
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
>;
Enter fullscreen mode Exit fullscreen mode
Frontend:
resolver:
zodResolver(
registerSchema
)
Enter fullscreen mode Exit fullscreen mode
Backend:
const result =
registerSchema.safeParse(
request.body
);
Enter fullscreen mode Exit fullscreen mode
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:
" [email protected] ",
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
);
}
Enter fullscreen mode Exit fullscreen mode
Output:
{
username:
"Karthik_2005",
email:
"[email protected]",
password:
"Password123",
confirmPassword:
"Password123",
acceptTerms:
true
}
Enter fullscreen mode Exit fullscreen mode
The schema did more than validate.
It also transformed selected values:
" [email protected] "
↓
"[email protected]"
Enter fullscreen mode Exit fullscreen mode
Common validation mistakes
Mistake 1: Using TypeScript assertions as validation
const data =
request.body as RegisterInput;
Enter fullscreen mode Exit fullscreen mode
This does not validate the request.
Use:
const result =
registerSchema
.safeParse(
request.body
);
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
Mistake 4: Ignoring transformed types
A schema can transform:
string input
↓
Date output
Enter fullscreen mode Exit fullscreen mode
or:
string input
↓
number output
Enter fullscreen mode Exit fullscreen mode
In those cases:
z.input<typeof schema>
Enter fullscreen mode Exit fullscreen mode
and:
z.output<typeof schema>
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
Install the dependencies
npm install \
react-hook-form \
zod \
@hookform/resolvers
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
Step 1: Create the Zod schema
Create:
src/features/auth/register.schema.ts
Enter fullscreen mode Exit fullscreen mode
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
>;
Enter fullscreen mode Exit fullscreen mode
Why use z.object()?
z.object({
username: z.string(),
email: z.string(),
});
Enter fullscreen mode Exit fullscreen mode
z.object() describes the expected shape of the complete form.
Registration object
├── username: string
├── displayName: string
├── email: string
├── password: string
├── confirmPassword: string
└── acceptTerms: boolean
Enter fullscreen mode Exit fullscreen mode
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)
Enter fullscreen mode Exit fullscreen mode
Password confirmation depends on two fields:
values.password ===
values.confirmPassword
Enter fullscreen mode Exit fullscreen mode
That is a cross-field rule.
.refine(
(values) =>
values.password ===
values.confirmPassword,
{
path: [
"confirmPassword",
],
message:
"Passwords do not match.",
}
)
Enter fullscreen mode Exit fullscreen mode
The path is essential.
Without it, the issue may belong to the complete object.
With:
path: [
"confirmPassword",
]
Enter fullscreen mode Exit fullscreen mode
the error is associated with:
errors.confirmPassword
Enter fullscreen mode Exit fullscreen mode
Step 2: Create the API layer
Create:
src/features/auth/register.api.ts
Enter fullscreen mode Exit fullscreen mode
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 ===
"[email protected]"
) {
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,
},
},
};
}
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
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;
Enter fullscreen mode Exit fullscreen mode
Why forwardRef() is required
React Hook Form supplies a ref through:
register("email")
Enter fullscreen mode Exit fullscreen mode
Our custom component must pass that ref to the real input.
React Hook Form ref
↓
FormInput component
↓
forwardRef
↓
native <input>
Enter fullscreen mode Exit fullscreen mode
Without forwarding:
<input ref={ref} />
Enter fullscreen mode Exit fullscreen mode
the registration ref would stop at the custom component boundary.
Step 4: Create the registration form
Create:
src/features/auth/RegisterForm.tsx
Enter fullscreen mode Exit fullscreen mode
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>
);
}
Enter fullscreen mode Exit fullscreen mode
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>
);
}
Enter fullscreen mode Exit fullscreen mode
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;
}
Enter fullscreen mode Exit fullscreen mode
Understanding the useForm generic types
We used:
useForm<
RegisterInput,
unknown,
RegisterOutput
>()
Enter fullscreen mode Exit fullscreen mode
The three generic positions represent:
useForm<
Input,
Context,
Output
>
Enter fullscreen mode Exit fullscreen mode
The resolver documentation shows this input/context/output form for schemas that transform data.
In our schema:
type RegisterInput =
z.input<
typeof registerSchema
>;
Enter fullscreen mode Exit fullscreen mode
describes values before schema parsing.
type RegisterOutput =
z.output<
typeof registerSchema
>;
Enter fullscreen mode Exit fullscreen mode
describes values after parsing and transformations.
Our email transformation changes:
[email protected]
↓
[email protected]
Enter fullscreen mode Exit fullscreen mode
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
)
Enter fullscreen mode Exit fullscreen mode
When the form validates, the resolver conceptually performs:
const result =
registerSchema
.safeParse(values);
Enter fullscreen mode Exit fullscreen mode
If successful:
{
values: result.data,
errors: {}
}
Enter fullscreen mode Exit fullscreen mode
If unsuccessful, it maps Zod issues into field errors:
{
values: {},
errors: {
email: {
type:
"invalid_format",
message:
"Enter a valid email address."
}
}
}
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
How one error reaches only one input
Suppose Zod produces an issue at:
path: [
"email",
]
Enter fullscreen mode Exit fullscreen mode
The resolver converts it into:
errors.email
Enter fullscreen mode Exit fullscreen mode
Our component passes:
error={
errors.email?.message
}
Enter fullscreen mode Exit fullscreen mode
only to the email input:
<FormInput
id="email"
error={
errors.email?.message
}
/>
Enter fullscreen mode Exit fullscreen mode
The username input reads:
errors.username
Enter fullscreen mode Exit fullscreen mode
The password input reads:
errors.password
Enter fullscreen mode Exit fullscreen mode
Therefore:
errors.email
↓
Email FormInput
↓
email-error paragraph
Enter fullscreen mode Exit fullscreen mode
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.",
}
)
Enter fullscreen mode Exit fullscreen mode
The path produces:
errors.confirmPassword
Enter fullscreen mode Exit fullscreen mode
Then:
<FormInput
error={
errors.confirmPassword
?.message
}
/>
Enter fullscreen mode Exit fullscreen mode
displays it only under the confirmation field.
Cross-field comparison fails
↓
Zod issue path:
confirmPassword
↓
errors.confirmPassword
↓
Confirmation input error
Enter fullscreen mode Exit fullscreen mode
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"
}
Enter fullscreen mode Exit fullscreen mode
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,
});
}
Enter fullscreen mode Exit fullscreen mode
The frontend provides fast feedback.
The backend enforces correctness.
Accessibility review
Our reusable input connects:
<label htmlFor={id}>
Enter fullscreen mode Exit fullscreen mode
to:
<input id={id} />
Enter fullscreen mode Exit fullscreen mode
When an error exists:
aria-invalid={true}
Enter fullscreen mode Exit fullscreen mode
and:
aria-describedby={
`${id}-error`
}
Enter fullscreen mode Exit fullscreen mode
connect the field to:
<p id={`${id}-error`}>
Enter fullscreen mode Exit fullscreen mode
The relationship is:
Input
aria-describedby="email-error"
↓
Error paragraph
id="email-error"
Enter fullscreen mode Exit fullscreen mode
The form also uses:
role="alert"
Enter fullscreen mode Exit fullscreen mode
for a root server error.
Performance notes
Using Zod does not mean validation is free.
Validation still performs work.
Be deliberate about:
mode: "onChange"
Enter fullscreen mode Exit fullscreen mode
for large schemas because it can validate frequently.
A common balanced configuration is:
mode: "onBlur"
Enter fullscreen mode Exit fullscreen mode
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;
}
Enter fullscreen mode Exit fullscreen mode
and:
const registerSchema =
z.object({
username: z.string(),
email: z.string(),
});
Enter fullscreen mode Exit fullscreen mode
Prefer inference:
type RegisterInput =
z.input<
typeof registerSchema
>;
Enter fullscreen mode Exit fullscreen mode
Mistake 2: Forgetting the resolver
Defining a schema does not automatically connect it to the form.
This:
const schema =
z.object({
email: z.string(),
});
Enter fullscreen mode Exit fullscreen mode
does nothing by itself.
Connect it:
useForm({
resolver:
zodResolver(schema),
});
Enter fullscreen mode Exit fullscreen mode
Mistake 3: Adding duplicate rules
Avoid unnecessarily validating the same field in both:
register("email", {
required:
"Email is required.",
})
Enter fullscreen mode Exit fullscreen mode
and:
z.string().min(
1,
"Email is required."
)
Enter fullscreen mode Exit fullscreen mode
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.",
}
)
Enter fullscreen mode Exit fullscreen mode
The error may be treated as an object-level issue.
Better:
.refine(
passwordsMatch,
{
path: [
"confirmPassword",
],
message:
"Passwords do not match.",
}
)
Enter fullscreen mode Exit fullscreen mode
Mistake 5: Trimming passwords automatically
This may be dangerous:
password:
z
.string()
.trim()
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
It cannot independently know:
Email already exists
Enter fullscreen mode Exit fullscreen mode
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.",
});
Enter fullscreen mode Exit fullscreen mode
Senior engineer tips
Keep schemas outside components
Better:
register.schema.ts
RegisterForm.tsx
Enter fullscreen mode Exit fullscreen mode
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>;
Enter fullscreen mode Exit fullscreen mode
This becomes important when converting:
"21"
↓
21
Enter fullscreen mode Exit fullscreen mode
or:
"2026-08-24"
↓
Date object
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
Keep the API layer separate
Avoid placing every Fetch detail inside the form component.
Better:
RegisterForm
↓
registerUser()
↓
HTTP request
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
toward:
Native input stores value
↓
register connects field
↓
Form control tracks state
↓
Subscribed UI updates
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
Validation libraries then moved rules from scattered conditions into reusable schemas.
Manual conditions
↓
Schema validation
↓
Runtime safety
↓
Type inference
↓
Shared validation contracts
Enter fullscreen mode Exit fullscreen mode
Finally, React Hook Form and Zod combined their responsibilities:
React Hook Form
→ form behavior
Zod
→ validation contract
zodResolver
→ connection between them
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode