The Evolution of Web Forms — Part 3
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" ); Reac

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 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. 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. <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 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 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. 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 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. 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. 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 isDirty changes isValid changes 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. 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. 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. 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; } const [email, setEmail] = useState(""); <input value={email} onChange={(event) => { setEmail(event.target.value); }} /> <input {...register("email")} /> 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() 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 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 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. 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. 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. 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. 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. useForm() useForm() creates the form-control system. const form = useForm<RegistrationValues>(); Most applications destructure the required methods: const { register, handleSubmit, formState: { errors, }, } = useForm<RegistrationValues>(); useForm() options useForm<RegistrationValues>({ defaultValues: { username: "", email: "", password: "", }, mode: "onBlur", shouldUnregister: false, }); Important options include: defaultValues mode reValidateMode resolver shouldUnregister criteriaMode context disabled 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 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. 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. 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. 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." ); }, }); 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. 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. 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. 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> 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. setValue() Selecting an address function selectAddress( address: Address ) { setValue( "address.city", address.city, { shouldDirty: true, } ); } setValue( "fullName", extractedDocument.name, { shouldDirty: true, shouldValidate: true, } ); setValue( "username", suggestedUsername, { shouldDirty: true, shouldValidate: true, } ); 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. 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 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. 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( { 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. 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. 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. async function goToNextStep() { const stepIsValid = await trigger([ "firstName", "lastName", "email", ]); if (!stepIsValid) { return; } setCurrentStep(2); } Only move forward when the current step is valid. control control is the internal public control object used by advanced React Hook Form APIs. const { control, } = useForm(); You normally pass it to: Controller useController useWatch useFieldArray useFormState Do not directly modify control. React Hook Form documents control as the object used to register and coordinate components with the form system. 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. 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 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. 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. The following example demonstrates: useForm register handleSubmit watch setValue getValues setError clearErrors reset resetField trigger control Controller errors isDirty dirtyFields touchedFields fieldState.isTouched isSubmitting isValid defaultValues shouldUnregister mode 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> ); } 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. 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. 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"); Without a reliable baseline, dirty-state comparisons and reset behavior can become confusing. Prefer: useForm({ defaultValues: { email: "", password: "", }, }); 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. 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. watch() and getValues()? watch() subscribes to value changes and can cause reactive UI updates. getValues() reads current values without subscribing to future changes. 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(). reset() and resetField()? reset() resets the complete form. resetField() resets one registered field and its selected state. 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. 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 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. 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 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 ); } } Mature ecosystem Expressive transformations Strong history with Formik Nested and conditional rules TypeScript inference Async validation support 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 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 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 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 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. Modular API Strong TypeScript inference Tree-shaking-friendly design Small client bundles Runtime transformations Works across browser and server environments 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 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 Validation reads like tests Framework-independent Focused field validation Stateful validation workflows Async race-condition support Useful for multi-step and conditional forms 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 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 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. 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 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" const data = request.body as RegisterInput; This does not validate the request. Use: const result = registerSchema .safeParse( request.body ); 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. Schemas are useful, but a 1,000-line schema can become difficult to maintain. Separate: Structural validation Business services Database constraints Authorization 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. 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. parse() and safeParse() in Zod? parse() returns validated data or throws an error. safeParse() returns a success-or-failure result object. 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. 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. 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 npm install \ react-hook-form \ zod \ @hookform/resolvers The resolver package officially supports connecting React Hook Form to Zod and inferring schema output types. 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 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 >; 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. 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 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. 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; 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. 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> ); } Create src/App.tsx: import RegisterForm from "./features/auth/RegisterForm"; export default function App() { return ( <main className="page"> <RegisterForm /> </main> ); } 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; } 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. 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. 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 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. 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 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. 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. 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 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 >; 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), }); 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. 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.", } ) 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. 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.", }); Better: register.schema.ts RegisterForm.tsx Avoid redefining a large schema every time the component function runs. 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 Good validation boundaries include: Form submission API request body Environment variables External API response File contents Queue messages Database JSON fields Avoid placing every Fetch detail inside the form component. Better: RegisterForm ↓ registerUser() ↓ HTTP request The form handles interface behavior. The API function handles communication. A resolver adapts the result of an external validation library into React Hook Form’s expected values-and-errors format. React Hook Form manages interactive form state and field registration. Zod manages runtime data validation, transformations, cross-field rules, and inferred TypeScript types. 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. 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. Yes. Frontend code can be bypassed. The backend must validate every untrusted request independently. 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
Key Takeaways
- •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" ); Reac
- •This story was reported by Dev.to, covering developments in the dev space.
- •AI advancements continue to reshape industries — read the full article on Dev.to for complete coverage.
📖 Continue reading the full article:
Read Full Article on Dev.to →


