Technology Aug 24, 2026 · 48 min read

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", "t...

DE
DEV Community
by Karthik Reddy
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"
);

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:

  1. React Hook Form’s philosophy and architecture
  2. React Hook Form’s core APIs
  3. Validation libraries
  4. React Hook Form with Zod and TypeScript

By the end, we will build a production-style registration form using:

React
+
TypeScript
+
React Hook Form
+
Zod
+
An API layer

Stage 9: React Hook Form Deep Dive

React Hook Form is not simply a shorter way to write controlled React forms.

It uses a different architectural philosophy.

A traditional controlled input stores its value in React state:

const [email, setEmail] =
  useState("");

<input
  value={email}
  onChange={(event) => {
    setEmail(event.target.value);
  }}
/>

Every keystroke produces a state update:

User types
    ↓
onChange runs
    ↓
setEmail runs
    ↓
Component renders again
    ↓
Input receives the new value

React Hook Form prefers native, uncontrolled inputs when possible.

<input
  {...register("email")}
/>

The browser stores the current value inside the input element.

React Hook Form registers the input, listens to its events, tracks relevant form state, and reads its value when required. React Hook Form’s official documentation describes register() as the mechanism that connects an input to validation, value tracking, and submission.

Controlled versus uncontrolled inputs

Controlled input

const [email, setEmail] =
  useState("");

<input
  value={email}
  onChange={(event) => {
    setEmail(event.target.value);
  }}
/>

React owns the value.

React state
    ↓
input value

The input cannot independently keep a different value because React continuously supplies it.

Uncontrolled input

<input
  name="email"
  defaultValue=""
/>

The browser owns the current value.

DOM input element
    ↓
current value

React may provide the initial value, but it does not need to update React state after every keystroke.

The current value can be read through:

  • A ref
  • FormData
  • Native form submission
  • A form-management library

A native uncontrolled input using useRef

Before understanding React Hook Form, let us manually build one uncontrolled input.

import {
  FormEvent,
  useRef,
} from "react";

export default function UncontrolledForm() {
  const emailRef =
    useRef<HTMLInputElement>(null);

  function handleSubmit(
    event: FormEvent<HTMLFormElement>
  ) {
    event.preventDefault();

    const email =
      emailRef.current?.value ?? "";

    console.log({ email });
  }

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="email">
        Email
      </label>

      <input
        id="email"
        name="email"
        type="email"
        ref={emailRef}
      />

      <button type="submit">
        Submit
      </button>
    </form>
  );
}

Notice what is missing:

value={email}
onChange={handleChange}

The browser stores the entered value.

On submission, we access the element through:

emailRef.current

and read:

emailRef.current.value

What is a ref?

A ref gives JavaScript access to an element or another persistent value.

const emailRef =
  useRef<HTMLInputElement>(null);

After React connects the ref to the input:

<input ref={emailRef} />

the ref may contain the actual DOM element:

emailRef.current
        ↓
HTMLInputElement

We can then access browser properties:

emailRef.current?.value;
emailRef.current?.focus();
emailRef.current?.disabled;
emailRef.current?.files;

React Hook Form uses refs as part of registering native inputs.

What does register() return?

Consider:

const {
  register,
} = useForm();

const registration =
  register("email");

console.log(registration);

Conceptually, the returned object resembles:

{
  name: "email",
  onChange: function,
  onBlur: function,
  ref: function
}

When we write:

<input
  {...register("email")}
/>

the spread operator applies those properties to the input.

Conceptually, it becomes:

<input
  name="email"
  onChange={registeredOnChange}
  onBlur={registeredOnBlur}
  ref={registeredRef}
/>

React Hook Form can now:

  • Identify the field by name
  • Track changes
  • Track blur events
  • Access the input element
  • Read the field value
  • Run validation
  • Focus the input after an error
  • Include the value during submission

Why the field name matters

register("email")

registers a field under the key:

email

The submitted object becomes:

{
  email: "karthik@example.com"
}

A nested field name:

register("address.city")

can produce:

{
  address: {
    city: "Kadapa"
  }
}

An array-style field:

register(
  "experiences.0.company"
)

can produce:

{
  experiences: [
    {
      company: "Example Company"
    }
  ]
}

The name is not merely an HTML detail.

It is the path React Hook Form uses to organize the form data and errors.

React Hook Form’s conceptual architecture

The exact internal implementation can change between versions, so application code should not depend on private internals.

However, the public architecture can be understood conceptually.

                   useForm()
                      ↓
              Form control object
                      ↓
       ┌──────────────┼──────────────┐
       ↓              ↓              ↓
Registered fields   Form state    Subscriptions
       ↓              ↓              ↓
name, ref,         errors,        Notify only
events, rules      dirty, etc.    interested UI
       ↓
Native DOM inputs
store current values

When an input changes:

User types
    ↓
Native input value changes
    ↓
Registered onChange runs
    ↓
React Hook Form updates internal field state
    ↓
Validation may run
    ↓
Only subscribed form state is notified

The browser can keep the current input value without requiring the parent component to store that value in React state after every keystroke.

Why fewer rerenders can happen

In a manually controlled form:

User types into email
        ↓
setValues()
        ↓
Form component renders
        ↓
All JSX inside the form is recalculated

With React Hook Form and a native registered input:

User types into email
        ↓
DOM input stores the value
        ↓
React Hook Form records relevant changes
        ↓
Only subscribed form-state consumers
need to update

This does not mean React Hook Form never rerenders.

Rerenders can still happen when:

  • An error appears or disappears
  • 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.

Understanding form-state subscriptions

Suppose a component reads:

const {
  formState: {
    errors,
  },
} = useForm();

The component is interested in errors.

If it also reads:

const {
  formState: {
    errors,
    isDirty,
    isSubmitting,
  },
} = useForm();

it is interested in three pieces of form state.

Conceptually:

Component subscribes to:
├── errors
├── isDirty
└── isSubmitting

This is one reason destructuring the state that the component needs is important.

React Hook Form does not eliminate state

A common misunderstanding is:

React Hook Form does not use state.

That is not accurate.

React Hook Form still manages state such as:

errors
dirty fields
touched fields
submission state
validation state
registered fields
default values

The difference is where the state is stored, how it is updated, and which components are notified.

Complete React Hook Form example

Install React Hook Form:

npm install react-hook-form

Create src/App.tsx:

import {
  SubmitHandler,
  useForm,
} from "react-hook-form";

interface RegistrationValues {
  username: string;
  email: string;
  password: string;
}

const defaultValues:
  RegistrationValues = {
    username: "",
    email: "",
    password: "",
  };

export default function App() {
  const {
    register,
    handleSubmit,
    reset,
    formState: {
      errors,
      isDirty,
      isSubmitting,
    },
  } =
    useForm<RegistrationValues>({
      defaultValues,
      mode: "onBlur",
    });

  const onSubmit:
    SubmitHandler<
      RegistrationValues
    > = async (values) => {
      await new Promise(
        (resolve) => {
          setTimeout(resolve, 1000);
        }
      );

      console.log(
        "Submitted values:",
        values
      );

      reset();
    };

  console.log(
    "Registration form rendered"
  );

  return (
    <main className="page">
      <form
        className="form"
        onSubmit={
          handleSubmit(onSubmit)
        }
        noValidate
      >
        <h1>Create an account</h1>

        <p>
          {isDirty
            ? "You have unsaved changes."
            : "No changes yet."}
        </p>

        <div className="field">
          <label htmlFor="username">
            Username
          </label>

          <input
            id="username"
            type="text"
            autoComplete="username"
            aria-invalid={Boolean(
              errors.username
            )}
            aria-describedby={
              errors.username
                ? "username-error"
                : undefined
            }
            {...register(
              "username",
              {
                required:
                  "Username is required.",

                minLength: {
                  value: 3,
                  message:
                    "Username must contain at least 3 characters.",
                },

                maxLength: {
                  value: 20,
                  message:
                    "Username cannot exceed 20 characters.",
                },

                pattern: {
                  value:
                    /^[A-Za-z0-9_]+$/,

                  message:
                    "Use only letters, numbers, and underscores.",
                },
              }
            )}
          />

          {errors.username && (
            <p
              id="username-error"
              className="error"
            >
              {
                errors.username
                  .message
              }
            </p>
          )}
        </div>

        <div className="field">
          <label htmlFor="email">
            Email
          </label>

          <input
            id="email"
            type="email"
            autoComplete="email"
            aria-invalid={Boolean(
              errors.email
            )}
            aria-describedby={
              errors.email
                ? "email-error"
                : undefined
            }
            {...register("email", {
              required:
                "Email is required.",

              pattern: {
                value:
                  /^[^\s@]+@[^\s@]+\.[^\s@]+$/,

                message:
                  "Enter a valid email address.",
              },
            })}
          />

          {errors.email && (
            <p
              id="email-error"
              className="error"
            >
              {errors.email.message}
            </p>
          )}
        </div>

        <div className="field">
          <label htmlFor="password">
            Password
          </label>

          <input
            id="password"
            type="password"
            autoComplete="new-password"
            aria-invalid={Boolean(
              errors.password
            )}
            aria-describedby={
              errors.password
                ? "password-error"
                : undefined
            }
            {...register(
              "password",
              {
                required:
                  "Password is required.",

                minLength: {
                  value: 8,
                  message:
                    "Password must contain at least 8 characters.",
                },
              }
            )}
          />

          {errors.password && (
            <p
              id="password-error"
              className="error"
            >
              {
                errors.password
                  .message
              }
            </p>
          )}
        </div>

        <div className="actions">
          <button
            type="button"
            onClick={() => {
              reset();
            }}
            disabled={
              isSubmitting ||
              !isDirty
            }
          >
            Reset
          </button>

          <button
            type="submit"
            disabled={isSubmitting}
          >
            {isSubmitting
              ? "Creating account..."
              : "Register"}
          </button>
        </div>
      </form>
    </main>
  );
}

Add src/index.css:

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  background: #f4f4f5;
  font-family:
    Inter,
    Arial,
    sans-serif;
}

button,
input {
  font: inherit;
}

.page {
  min-height: 100vh;
  display: grid;
  place-items: center;
  padding: 24px;
}

.form {
  width: min(100%, 480px);
  display: grid;
  gap: 18px;
  padding: 28px;
  background: white;
  border-radius: 12px;
}

.field {
  display: grid;
  gap: 6px;
}

input {
  width: 100%;
  padding: 10px 12px;
  border: 1px solid #71717a;
  border-radius: 6px;
}

input[aria-invalid="true"] {
  border-color: #b91c1c;
}

.error {
  margin: 0;
  color: #b91c1c;
  font-size: 14px;
}

.actions {
  display: flex;
  justify-content: flex-end;
  gap: 12px;
}

button {
  padding: 10px 14px;
  cursor: pointer;
}

button:disabled {
  cursor: not-allowed;
  opacity: 0.65;
}

Controlled React versus React Hook Form

Controlled form

const [email, setEmail] =
  useState("");

<input
  value={email}
  onChange={(event) => {
    setEmail(event.target.value);
  }}
/>

React Hook Form

<input
  {...register("email")}
/>

Comparison

Concern Controlled React React Hook Form
Current native input value React state Usually the DOM input
Change handler Manually written Supplied by register()
Blur handler Manually written Supplied by register()
Field ref Manually managed Supplied by register()
Errors Manually stored formState.errors
Dirty state Manually calculated isDirty, dirtyFields
Touched state Manually calculated touchedFields
Submission Manually validated handleSubmit()
Reset Manually coordinated reset()
Server errors Manually mapped setError()

Advantages of React Hook Form’s architecture

  • Less boilerplate for native inputs
  • Input values do not always require parent state
  • Built-in dirty and touched tracking
  • Field-level validation
  • Nested field support
  • Better support for large forms
  • Easy integration with schema validators
  • Type-safe field names with TypeScript
  • Built-in focus management for many errors
  • APIs for server-side errors

Disadvantages

  • The uncontrolled model may initially feel unfamiliar
  • register() hides several props inside a spread
  • Custom controlled components require additional integration
  • Broad use of watch() can cause more rerenders
  • Incorrect default values can produce confusing dirty-state behavior
  • Dynamic forms require careful field naming
  • Understanding subscriptions is still necessary for optimization

Common beginner mistake: overwriting registered handlers

Consider:

<input
  {...register("email")}
  onChange={handleEmailChange}
/>

The later onChange can replace the handler supplied by register().

A safer approach is to place custom behavior in the registration options:

<input
  {...register("email", {
    onChange: (event) => {
      console.log(
        event.target.value
      );
    },
  })}
/>

Or explicitly compose the handlers.

Common beginner mistake: losing the ref

A reusable input component must forward the supplied ref if it is used with register().

If the ref stops at the React component and never reaches the real input, React Hook Form may not be able to register the element correctly.

Interview question

Why can React Hook Form cause fewer rerenders than a traditional controlled form?

A traditional controlled form usually updates React state after every keystroke.

React Hook Form can let the native input retain its current value while tracking form state through registration, refs, events, and subscriptions. React components then update mainly when subscribed state such as errors, dirty status, watched values, or submission state changes.

Why this evolved

React Hook Form reduced form boilerplate and avoided forcing every native input value through parent React state. However, developers still needed to understand its public APIs for reading values, setting errors, controlling validation, resetting fields, and integrating non-native components.

Stage 10: React Hook Form APIs

React Hook Form exposes many methods.

Do not try to memorize all of them at once.

Instead, organize them by responsibility.

Form creation
└── useForm

Field connection
├── register
├── control
└── Controller

Submission
└── handleSubmit

Read values
├── watch
└── getValues

Change values
└── setValue

Errors and validation
├── setError
├── clearErrors
└── trigger

Reset state
├── reset
└── resetField

Form status
└── formState

The official useForm() hook initializes the form and exposes these methods and state objects.

1. useForm()

useForm() creates the form-control system.

const form =
  useForm<RegistrationValues>();

Most applications destructure the required methods:

const {
  register,
  handleSubmit,
  formState: {
    errors,
  },
} =
  useForm<RegistrationValues>();

Important useForm() options

useForm<RegistrationValues>({
  defaultValues: {
    username: "",
    email: "",
    password: "",
  },

  mode: "onBlur",

  shouldUnregister: false,
});

Important options include:

  • defaultValues
  • mode
  • reValidateMode
  • resolver
  • shouldUnregister
  • criteriaMode
  • context
  • disabled

2. defaultValues

const defaultValues = {
  username: "",
  email: "",
  password: "",
};

useForm({
  defaultValues,
});

Default values serve as the baseline for:

  • Initial field values
  • Reset behavior
  • Dirty comparison
Default email:
""

Current email:
"karthik@example.com"

isDirty:
true

If the current value returns to the default:

Current email:
""

isDirty:
false

Use consistent values.

For text inputs, prefer:

email: ""

instead of:

email: undefined

Asynchronous default values

When editing existing data, default values may come from an API.

useForm<UserFormValues>({
  defaultValues: async () => {
    const response =
      await fetch("/api/me");

    if (!response.ok) {
      throw new Error(
        "Unable to load user"
      );
    }

    return response.json();
  },
});

For externally loaded data, reset() is also commonly used after the data arrives.

3. mode

mode controls when initial validation runs.

useForm({
  mode: "onSubmit",
});

Common modes include:

Mode Validation timing
onSubmit When the user submits
onBlur When the user leaves a field
onChange As the value changes
onTouched After initial interaction
all Blur and change interactions

Example:

useForm({
  mode: "onBlur",
});

This provides a balanced experience:

User types
    ↓
No immediate interruption
    ↓
User leaves field
    ↓
Validate field

Using onChange can provide immediate feedback, but it may also create more validation work and a noisier user experience.

4. shouldUnregister

Imagine a conditional field:

{hasCompany && (
  <input
    {...register(
      "companyName"
    )}
  />
)}

When companyName disappears, should its value remain in the form?

With the default preservation-oriented behavior:

shouldUnregister: false

an unmounted field can remain represented in form data.

With:

shouldUnregister: true

an unmounted field is removed from registration and its value is not retained in the same way. React Hook Form documents shouldUnregister as controlling whether fields are removed after unmount.

Use true when hidden fields should behave like native fields that no longer exist.

Use false when temporarily hidden steps should preserve their data.

5. register()

register() connects a native field to React Hook Form.

<input
  {...register("email")}
/>

It can also accept validation rules:

<input
  {...register("email", {
    required:
      "Email is required.",

    pattern: {
      value:
        /^[^\s@]+@[^\s@]+\.[^\s@]+$/,

      message:
        "Enter a valid email address.",
    },
  })}
/>

Common registration rules include:

register("age", {
  required: "Age is required.",

  min: {
    value: 18,
    message:
      "You must be at least 18.",
  },

  max: {
    value: 100,
    message:
      "Enter a valid age.",
  },

  valueAsNumber: true,

  validate: (value) => {
    return (
      Number.isInteger(value) ||
      "Age must be a whole number."
    );
  },
});

Custom validation with validate

register("username", {
  validate: {
    noSpaces: (value) => {
      return (
        !value.includes(" ") ||
        "Username cannot contain spaces."
      );
    },

    notAdmin: (value) => {
      return (
        value.toLowerCase() !==
          "admin" ||
        "This username is reserved."
      );
    },
  },
});

A validation function can return:

true

when valid, or:

an error message

when invalid.

6. handleSubmit()

handleSubmit() coordinates validation and submission.

<form
  onSubmit={
    handleSubmit(onSubmit)
  }
>

The success callback receives validated values:

const onSubmit = (
  values: RegistrationValues
) => {
  console.log(values);
};

You can also provide an invalid callback:

const onInvalid = (
  errors: FieldErrors<
    RegistrationValues
  >
) => {
  console.log(
    "Validation failed:",
    errors
  );
};

<form
  onSubmit={
    handleSubmit(
      onSubmit,
      onInvalid
    )
  }
/>

The process is:

Native submit event
       ↓
handleSubmit
       ↓
Run validation
      / \
   Invalid Valid
      ↓     ↓
 onInvalid onSubmit

handleSubmit() validates before invoking the success callback and can pass typed form data to it.

7. watch()

watch() subscribes to value changes.

const country =
  watch("country");

You can use the value for conditional rendering:

{country === "india" && (
  <input
    {...register("state")}
  />
)}

Watch multiple fields:

const [
  password,
  confirmPassword,
] = watch([
  "password",
  "confirmPassword",
]);

Watch the entire form:

const values = watch();

Be careful with broad watches.

Watching the complete form means the component may update for changes across all watched fields.

React Hook Form documents watch() as a method for observing field values and rendering conditional UI.

watch() versus getValues()

These methods can return similar data but have different purposes.

const email =
  watch("email");

watch() subscribes to changes.

const email =
  getValues("email");

getValues() reads the current value without subscribing the component to value changes. React Hook Form specifically documents getValues() as reading values without subscribing to rerenders.

Use:

watch()

when the UI must react to changes.

Use:

getValues()

when you only need a value at a particular moment.

8. getValues()

Read every field:

const values =
  getValues();

Read one field:

const email =
  getValues("email");

Read several fields:

const [
  email,
  username,
] = getValues([
  "email",
  "username",
]);

Example:

function handlePreview() {
  const values =
    getValues();

  console.log(
    "Preview:",
    values
  );
}
<button
  type="button"
  onClick={handlePreview}
>
  Preview current data
</button>

9. setValue()

setValue() updates a field programmatically.

setValue(
  "email",
  "karthik@example.com"
);

Options can update related form state:

setValue(
  "email",
  "karthik@example.com",
  {
    shouldValidate: true,
    shouldDirty: true,
    shouldTouch: true,
  }
);

React Hook Form supports using setValue() to change a registered value while optionally validating it or marking it dirty and touched.

Production examples for setValue()

Selecting an address

function selectAddress(
  address: Address
) {
  setValue(
    "address.city",
    address.city,
    {
      shouldDirty: true,
    }
  );
}

OCR document extraction

setValue(
  "fullName",
  extractedDocument.name,
  {
    shouldDirty: true,
    shouldValidate: true,
  }
);

Choosing a suggested username

setValue(
  "username",
  suggestedUsername,
  {
    shouldDirty: true,
    shouldValidate: true,
  }
);

10. setError()

setError() manually inserts an error.

setError("email", {
  type: "server",
  message:
    "Email already exists.",
});

The result becomes available through:

errors.email

You can then render:

{errors.email && (
  <p>
    {errors.email.message}
  </p>
)}

React Hook Form explicitly supports setError() for custom and server-side validation errors.

Root-level errors

Not every error belongs to one field.

setError("root.server", {
  type: "server",
  message:
    "The service is temporarily unavailable.",
});

Display it:

{errors.root?.server && (
  <p role="alert">
    {
      errors.root.server
        .message
    }
  </p>
)}

Examples of root errors:

  • Server unavailable
  • Unknown registration failure
  • Payment provider failure
  • Session expired
  • Too many requests
  • Unexpected response

11. clearErrors()

Clear one error:

clearErrors("email");

Clear multiple errors:

clearErrors([
  "email",
  "username",
]);

Clear all errors:

clearErrors();

React Hook Form documents clearErrors() as clearing one, several, or all current errors without itself rerunning validation.

Example:

<input
  {...register("email", {
    onChange: () => {
      clearErrors(
        "root.server"
      );
    },
  })}
/>

Use this when a global server error should disappear after the user begins correcting the form.

Do not clear a field error merely to make the UI look valid.

The value should still be revalidated when appropriate.

12. reset()

Reset the complete form:

reset();

Reset with new values:

reset({
  username: "karthik",
  email:
    "karthik@example.com",
  password: "",
});

This can be useful after fetching existing data:

useEffect(() => {
  if (user) {
    reset({
      username:
        user.username,
      email:
        user.email,
      password: "",
    });
  }
}, [user, reset]);

React Hook Form’s reset() API can restore values and form-state properties such as errors, touched fields, and dirty fields according to its supplied options.

Reset while preserving selected state

reset(
  {
    username: "",
    email: "",
    password: "",
  },
  {
    keepErrors: true,
    keepDirty: true,
  }
);

Use preservation options carefully.

After a successful registration, the common behavior is:

reset();

After an API refresh, you may intentionally preserve dirty user edits.

13. resetField()

Reset only one field:

resetField("email");

Reset to a new default value:

resetField("email", {
  defaultValue:
    "new@example.com",
});

Optionally preserve state:

resetField("email", {
  keepError: true,
  keepDirty: true,
  keepTouched: true,
});

React Hook Form documents resetField() as resetting one field’s value and state independently of the complete form.

14. trigger()

trigger() manually runs validation.

Validate the entire form:

const isValid =
  await trigger();

Validate one field:

const emailIsValid =
  await trigger("email");

Validate several fields:

const credentialsAreValid =
  await trigger([
    "email",
    "password",
  ]);

React Hook Form notes that trigger() is particularly useful when one field depends on another.

Multi-step form example

async function goToNextStep() {
  const stepIsValid =
    await trigger([
      "firstName",
      "lastName",
      "email",
    ]);

  if (!stepIsValid) {
    return;
  }

  setCurrentStep(2);
}

Only move forward when the current step is valid.

15. control

control is the internal public control object used by advanced React Hook Form APIs.

const {
  control,
} = useForm();

You normally pass it to:

  • 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.

16. Controller

Native HTML inputs work well with register().

However, some UI components are controlled components.

They may expose an API such as:

<RolePicker
  value={role}
  onChange={setRole}
/>

They do not expose a native input ref in the way register() expects.

Controller connects these components to React Hook Form.

<Controller
  name="role"
  control={control}
  render={({
    field,
    fieldState,
  }) => (
    <RolePicker
      value={field.value}
      onChange={field.onChange}
      onBlur={field.onBlur}
      error={
        fieldState.error
          ?.message
      }
    />
  )}
/>

Controller supplies:

field
├── name
├── value
├── onChange
├── onBlur
├── ref
└── disabled

fieldState
├── error
├── invalid
├── isDirty
└── isTouched

React Hook Form’s documentation places isTouched on an individual controller field’s fieldState.

Important correction: isTouched versus touchedFields

There is no commonly used top-level:

formState.isTouched

Instead, React Hook Form provides:

formState.touchedFields

for the collection of touched fields.

For one controlled field, Controller provides:

fieldState.isTouched

Conceptually:

Whole form:
touchedFields.email

One Controller field:
fieldState.isTouched

17. resolver

A resolver connects React Hook Form to an external validation library.

useForm({
  resolver:
    zodResolver(schema),
});

The resolver:

  1. Receives the form values.
  2. Passes them to the validation library.
  3. Receives validation results.
  4. Converts errors into React Hook Form’s error structure.
  5. Returns validated or transformed values.

React Hook Form’s resolver package supports several schema and validation libraries, including Zod, Yup, Valibot, and Vest.

We will implement this fully in Stage 12.

18. formState

const {
  formState,
} = useForm();

Common properties include:

const {
  errors,
  isDirty,
  dirtyFields,
  touchedFields,
  isSubmitting,
  isValid,
  isSubmitted,
  isSubmitSuccessful,
  submitCount,
  defaultValues,
} = formState;

React Hook Form documents formState as the source of current errors, dirty status, touched fields, submission status, and validity.

errors

errors.email

Possible shape:

{
  type: "required",
  message:
    "Email is required.",
  ref: HTMLInputElement
}

Render:

{errors.email && (
  <p>
    {errors.email.message}
  </p>
)}

isDirty

isDirty

Indicates whether the form currently differs from its default values.

<button
  disabled={!isDirty}
>
  Save changes
</button>

dirtyFields

dirtyFields

Identifies individual changed fields.

Example:

{
  email: true,
  address: {
    city: true
  }
}

Use it for:

  • Autosaving selected fields
  • Showing changed-field indicators
  • Building partial update requests
  • Warning about unsaved changes

touchedFields

touchedFields

Tracks fields that have been interacted with according to the form’s event lifecycle.

Example:

{
  email: true,
  password: true
}

isSubmitting

isSubmitting

Becomes true while an asynchronous submit callback is running.

<button
  disabled={isSubmitting}
>
  {isSubmitting
    ? "Saving..."
    : "Save"}
</button>

isValid

isValid

Represents whether the form currently satisfies its configured validation rules.

Its usefulness depends on the chosen validation mode.

For example:

useForm({
  mode: "onChange",
});

can keep validity more continuously updated, but it also runs validation more frequently.

defaultValues

The configured default values are also available through form state.

formState.defaultValues

This can be useful when comparing or displaying original values.

Complete API playground

The following example demonstrates:

  • 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>
  );
}

Common React Hook Form mistakes

Mistake 1: Using both register() and Controller

Bad:

<Controller
  name="email"
  control={control}
  render={({ field }) => (
    <input
      {...field}
      {...register("email")}
    />
  )}
/>

The field is registered twice.

Use either:

register("email")

or:

<Controller
  name="email"
  control={control}
/>

for that integration.

Mistake 2: Using Controller for every native input

This works, but it gives up some of the simplicity of uncontrolled native registration.

For a standard input, prefer:

<input
  {...register("email")}
/>

Use Controller when the component genuinely needs controlled integration.

Mistake 3: Calling watch() for everything

const values = watch();

This is convenient, but it causes the component to care about every form value.

Watch only what the UI requires:

const country =
  watch("country");

Mistake 4: Omitting default values

Without a reliable baseline, dirty-state comparisons and reset behavior can become confusing.

Prefer:

useForm({
  defaultValues: {
    email: "",
    password: "",
  },
});

Mistake 5: Treating getValues() as reactive

This does not subscribe:

const email =
  getValues("email");

The component will not automatically rerender merely because that email changes.

Use:

const email =
  watch("email");

when rendering depends on the current value.

Interview questions

What does register() do?

It connects an input to React Hook Form by supplying the field name, event handlers, and ref required for value tracking, validation, touched state, focus management, and submission.

What is the difference between watch() and getValues()?

watch() subscribes to value changes and can cause reactive UI updates.

getValues() reads current values without subscribing to future changes.

When should you use Controller?

Use Controller for controlled third-party or custom components that communicate through value, onChange, and related props instead of exposing a native input ref compatible with register().

What is the difference between reset() and resetField()?

reset() resets the complete form.

resetField() resets one registered field and its selected state.

Why this evolved

React Hook Form provided form-state management, but developers still needed a maintainable way to define complex validation rules. Inline rules worked for small forms, but large forms required reusable, testable validation schemas.

Stage 11: Validation Libraries

Validation can be written manually.

function validateEmail(
  email: string
) {
  if (!email.trim()) {
    return "Email is required.";
  }

  if (
    !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(
      email
    )
  ) {
    return "Enter a valid email.";
  }

  return undefined;
}

This is reasonable for one field.

But production validation often includes:

  • Nested objects
  • Arrays
  • Optional fields
  • Conditional fields
  • Transformations
  • Cross-field rules
  • Runtime type checking
  • Reusable frontend and backend rules
  • TypeScript inference
  • Structured error paths

TypeScript types do not validate runtime data

Consider:

interface User {
  name: string;
  age: number;
}

This helps while writing TypeScript.

But an API may return:

{
  "name": 123,
  "age": "twenty"
}

TypeScript interfaces do not run in the browser or server after compilation.

This assertion:

const user =
  responseData as User;

does not validate anything.

It only tells TypeScript:

Trust me. Treat this as User.

A runtime validation library actually checks the value.

Manual validation

Complete manual schema-like validator

interface RegistrationInput {
  username: string;
  email: string;
  password: string;
  confirmPassword: string;
}

type RegistrationErrors =
  Partial<
    Record<
      keyof RegistrationInput,
      string
    >
  >;

interface ValidationSuccess {
  success: true;
  data: RegistrationInput;
}

interface ValidationFailure {
  success: false;
  errors:
    RegistrationErrors;
}

type ValidationResult =
  | ValidationSuccess
  | ValidationFailure;

function validateRegistration(
  input: unknown
): ValidationResult {
  if (
    typeof input !==
      "object" ||
    input === null
  ) {
    return {
      success: false,
      errors: {
        username:
          "Invalid registration data.",
      },
    };
  }

  const candidate =
    input as Record<
      string,
      unknown
    >;

  const errors:
    RegistrationErrors = {};

  const username =
    typeof candidate.username ===
    "string"
      ? candidate.username.trim()
      : "";

  const email =
    typeof candidate.email ===
    "string"
      ? candidate.email
          .trim()
          .toLowerCase()
      : "";

  const password =
    typeof candidate.password ===
    "string"
      ? candidate.password
      : "";

  const confirmPassword =
    typeof candidate
      .confirmPassword ===
    "string"
      ? candidate
          .confirmPassword
      : "";

  if (!username) {
    errors.username =
      "Username is required.";
  } else if (
    username.length < 3
  ) {
    errors.username =
      "Username must contain at least 3 characters.";
  }

  if (!email) {
    errors.email =
      "Email is required.";
  } else if (
    !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(
      email
    )
  ) {
    errors.email =
      "Enter a valid email address.";
  }

  if (password.length < 8) {
    errors.password =
      "Password must contain at least 8 characters.";
  }

  if (
    confirmPassword !==
    password
  ) {
    errors.confirmPassword =
      "Passwords do not match.";
  }

  if (
    Object.keys(errors).length >
    0
  ) {
    return {
      success: false,
      errors,
    };
  }

  return {
    success: true,
    data: {
      username,
      email,
      password,
      confirmPassword,
    },
  };
}

This works.

But we manually implemented:

  • Unknown-data checking
  • String checking
  • Trimming
  • Error collection
  • Error paths
  • Result types
  • Cross-field validation
  • Data transformation

A schema library standardizes this work.

Yup

Yup is a runtime schema builder that supports validation, parsing, transformations, nested objects, and interdependent rules. Its official repository describes it as an object-schema system for runtime parsing and validation.

Install:

npm install yup

Example:

import * as yup from "yup";

const registrationSchema =
  yup
    .object({
      username:
        yup
          .string()
          .trim()
          .required(
            "Username is required."
          )
          .min(
            3,
            "Username must contain at least 3 characters."
          ),

      email:
        yup
          .string()
          .trim()
          .lowercase()
          .email(
            "Enter a valid email address."
          )
          .required(
            "Email is required."
          ),

      password:
        yup
          .string()
          .required(
            "Password is required."
          )
          .min(
            8,
            "Password must contain at least 8 characters."
          ),

      confirmPassword:
        yup
          .string()
          .required(
            "Confirm your password."
          )
          .oneOf(
            [
              yup.ref(
                "password"
              ),
            ],
            "Passwords do not match."
          ),
    })
    .required();

type RegistrationInput =
  yup.InferType<
    typeof registrationSchema
  >;

Validate:

try {
  const validated =
    await registrationSchema
      .validate(input, {
        abortEarly: false,
      });

  console.log(validated);
} catch (error) {
  if (
    error instanceof
    yup.ValidationError
  ) {
    console.log(
      error.inner
    );
  }
}

Yup advantages

  • Mature ecosystem
  • Expressive transformations
  • Strong history with Formik
  • Nested and conditional rules
  • TypeScript inference
  • Async validation support

Yup disadvantages

  • Some APIs depend heavily on chained transformations
  • Input/output behavior can require careful understanding
  • Conditional schemas can become difficult to read
  • Teams focused on TypeScript-first APIs may prefer alternatives

Zod

Zod is a TypeScript-first runtime validation library with static type inference.

Its schemas can validate values ranging from primitives to complex nested objects, and its current official documentation identifies Zod 4 as stable.

Install:

npm install zod

Example:

import { z } from "zod";

const registrationSchema =
  z
    .object({
      username:
        z
          .string()
          .trim()
          .min(
            1,
            "Username is required."
          )
          .min(
            3,
            "Username must contain at least 3 characters."
          ),

      email:
        z
          .string()
          .trim()
          .email(
            "Enter a valid email address."
          )
          .transform(
            (email) =>
              email.toLowerCase()
          ),

      password:
        z
          .string()
          .min(
            8,
            "Password must contain at least 8 characters."
          ),

      confirmPassword:
        z.string(),
    })
    .refine(
      (values) =>
        values.password ===
        values.confirmPassword,
      {
        path: [
          "confirmPassword",
        ],
        message:
          "Passwords do not match.",
      }
    );

type RegistrationInput =
  z.input<
    typeof registrationSchema
  >;

type RegistrationOutput =
  z.output<
    typeof registrationSchema
  >;

parse()

const validated =
  registrationSchema.parse(
    input
  );

If validation fails, parse() throws a Zod error.

safeParse()

const result =
  registrationSchema
    .safeParse(input);

if (!result.success) {
  console.log(
    result.error
  );
} else {
  console.log(
    result.data
  );
}

safeParse() returns a discriminated result rather than requiring a try/catch.

Validation result
      /       \
 success      failure
    ↓            ↓
 result.data  result.error

Zod advantages

  • TypeScript-first API
  • Static type inference
  • Runtime validation
  • Structured error paths
  • Transformations
  • Nested schemas
  • Cross-field validation
  • Wide integration ecosystem
  • Convenient safeParse() result
  • Suitable for client and server boundaries

Zod disadvantages

  • Schemas can become large
  • Complex transformations may create different input and output types
  • Cross-field validation requires deliberate error paths
  • Large client bundles may matter in extremely size-sensitive applications
  • A schema does not replace business or database validation

Valibot

Valibot is a modular TypeScript schema library designed around type safety, tree shaking, and smaller client bundles. Its official documentation emphasizes that unused schema actions can be removed by bundlers because of its modular API.

Install:

npm install valibot

Example:

import * as v from "valibot";

const registrationSchema =
  v.pipe(
    v.object({
      username:
        v.pipe(
          v.string(),
          v.trim(),
          v.nonEmpty(
            "Username is required."
          ),
          v.minLength(
            3,
            "Username must contain at least 3 characters."
          )
        ),

      email:
        v.pipe(
          v.string(),
          v.trim(),
          v.email(
            "Enter a valid email address."
          ),
          v.toLowerCase()
        ),

      password:
        v.pipe(
          v.string(),
          v.minLength(
            8,
            "Password must contain at least 8 characters."
          )
        ),

      confirmPassword:
        v.string(),
    }),

    v.forward(
      v.partialCheck(
        [
          [
            "password",
          ],
          [
            "confirmPassword",
          ],
        ],
        (input) =>
          input.password ===
          input.confirmPassword,
        "Passwords do not match."
      ),
      [
        "confirmPassword",
      ]
    )
  );

type RegistrationInput =
  v.InferInput<
    typeof registrationSchema
  >;

type RegistrationOutput =
  v.InferOutput<
    typeof registrationSchema
  >;

Validate:

const result =
  v.safeParse(
    registrationSchema,
    input
  );

if (result.success) {
  console.log(
    result.output
  );
} else {
  console.log(
    result.issues
  );
}

Valibot schemas run at runtime while also supporting inferred TypeScript types.

Valibot advantages

  • Modular API
  • Strong TypeScript inference
  • Tree-shaking-friendly design
  • Small client bundles
  • Runtime transformations
  • Works across browser and server environments

Valibot disadvantages

  • More functional and pipeline-oriented syntax
  • Smaller historical ecosystem than Yup or Zod
  • Teams familiar with chainable APIs may need adjustment
  • Advanced cross-field validation can initially look unfamiliar

Vest

Vest takes inspiration from unit-test syntax.

Instead of primarily describing one object schema, you write named validation tests.

Vest’s current documentation describes it as a validation system for workflows that change over time, including focused field validation and protection against outdated asynchronous results.

Install:

npm install vest

Example:

import {
  create,
  enforce,
  test,
} from "vest";

interface RegistrationInput {
  username: string;
  email: string;
  password: string;
  confirmPassword: string;
}

const registrationSuite =
  create(
    (
      data:
        RegistrationInput
    ) => {
      test(
        "username",
        "Username is required.",
        () => {
          enforce(
            data.username
          ).isNotBlank();
        }
      );

      test(
        "username",
        "Username must contain at least 3 characters.",
        () => {
          enforce(
            data.username
          ).longerThanOrEquals(
            3
          );
        }
      );

      test(
        "email",
        "Enter a valid email address.",
        () => {
          enforce(
            data.email
          ).matches(
            /^[^\s@]+@[^\s@]+\.[^\s@]+$/
          );
        }
      );

      test(
        "password",
        "Password must contain at least 8 characters.",
        () => {
          enforce(
            data.password
          ).longerThanOrEquals(
            8
          );
        }
      );

      test(
        "confirmPassword",
        "Passwords do not match.",
        () => {
          enforce(
            data.confirmPassword
          ).equals(
            data.password
          );
        }
      );
    }
  );

Run:

const result =
  registrationSuite.run(
    formData
  );

if (
  result.hasErrors(
    "email"
  )
) {
  console.log(
    result.getError(
      "email"
    )
  );
}

Vest is especially relevant for:

  • Multi-step forms
  • Asynchronous field validation
  • Progressive validation
  • Conditional workflows
  • Retaining validation results across focused runs

Vest advantages

  • Validation reads like tests
  • Framework-independent
  • Focused field validation
  • Stateful validation workflows
  • Async race-condition support
  • Useful for multi-step and conditional forms

Vest disadvantages

  • Different mental model from object schemas
  • May be unnecessary for simple payload parsing
  • Form state and data state still require architectural decisions
  • Less natural when the primary need is a single runtime object schema

Validation-library comparison

Approach Main style Type inference Transformations Best fit
Manual Functions and conditions Manual Manual Very small forms
Yup Chainable object schemas Yes Strong Existing Formik/Yup projects
Zod TypeScript-first schemas Strong Strong Full-stack TypeScript applications
Valibot Modular functional pipelines Strong Strong Bundle-sensitive TypeScript applications
Vest Test-like validation suites Supported Different focus Complex interactive validation workflows

Why Zod became a common choice

Zod fits naturally into TypeScript applications because one schema can provide:

Runtime validation
        +
TypeScript type inference
        +
Structured error paths
        +
Data transformation

Example:

const userSchema =
  z.object({
    name: z.string(),
    age:
      z
        .number()
        .int()
        .positive(),
  });

type User =
  z.infer<
    typeof userSchema
  >;

Without inference, a developer might write the same structure twice:

interface User {
  name: string;
  age: number;
}

const userSchema = {
  // Duplicate definition
};

Duplicated definitions can drift apart.

With inference:

Schema
  ├── validates runtime data
  └── generates TypeScript type

The official resolver integration can infer values from Zod and several other schema libraries.

Sharing frontend and backend schemas

A monorepo may contain:

apps/
├── web/
└── api/

packages/
└── validation/
    └── auth.schema.ts

Shared schema:

import { z } from "zod";

export const registerSchema =
  z.object({
    username:
      z
        .string()
        .trim()
        .min(3),

    email:
      z
        .string()
        .trim()
        .email(),

    password:
      z
        .string()
        .min(8),
  });

export type RegisterInput =
  z.input<
    typeof registerSchema
  >;

Frontend:

resolver:
  zodResolver(
    registerSchema
  )

Backend:

const result =
  registerSchema.safeParse(
    request.body
  );

This reduces accidental rule differences.

However, not every rule should be shared.

The backend may also enforce:

  • Email uniqueness
  • Username uniqueness
  • Database constraints
  • Authorization
  • Rate limits
  • Token validity
  • Account state
  • File scanning
  • Organization membership

Complete Zod validation example

Create validation-demo.ts:

import { z } from "zod";

const registrationSchema =
  z
    .object({
      username:
        z
          .string({
            message:
              "Username must be text.",
          })
          .trim()
          .min(
            1,
            "Username is required."
          )
          .min(
            3,
            "Username must contain at least 3 characters."
          )
          .max(
            20,
            "Username cannot exceed 20 characters."
          )
          .regex(
            /^[A-Za-z0-9_]+$/,
            "Use only letters, numbers, and underscores."
          ),

      email:
        z
          .string({
            message:
              "Email must be text.",
          })
          .trim()
          .email(
            "Enter a valid email address."
          )
          .transform(
            (email) =>
              email.toLowerCase()
          ),

      password:
        z
          .string()
          .min(
            8,
            "Password must contain at least 8 characters."
          )
          .max(
            72,
            "Password cannot exceed 72 characters."
          ),

      confirmPassword:
        z.string(),

      acceptTerms:
        z
          .boolean()
          .refine(
            (accepted) =>
              accepted,
            {
              message:
                "You must accept the terms.",
            }
          ),
    })
    .refine(
      (values) =>
        values.password ===
        values.confirmPassword,
      {
        path: [
          "confirmPassword",
        ],
        message:
          "Passwords do not match.",
      }
    );

type RegistrationInput =
  z.input<
    typeof registrationSchema
  >;

type RegistrationOutput =
  z.output<
    typeof registrationSchema
  >;

const input:
  RegistrationInput = {
    username: "  Karthik_2005  ",
    email:
      "  KARTHIK@EXAMPLE.COM  ",
    password:
      "Password123",
    confirmPassword:
      "Password123",
    acceptTerms: true,
  };

const result =
  registrationSchema
    .safeParse(input);

if (!result.success) {
  const fieldErrors =
    result.error.flatten()
      .fieldErrors;

  console.log(
    "Validation failed:",
    fieldErrors
  );
} else {
  const data:
    RegistrationOutput =
      result.data;

  console.log(
    "Validation succeeded:",
    data
  );
}

Output:

{
  username:
    "Karthik_2005",

  email:
    "karthik@example.com",

  password:
    "Password123",

  confirmPassword:
    "Password123",

  acceptTerms:
    true
}

The schema did more than validate.

It also transformed selected values:

"  KARTHIK@EXAMPLE.COM  "
              ↓
"karthik@example.com"

Common validation mistakes

Mistake 1: Using TypeScript assertions as validation

const data =
  request.body as RegisterInput;

This does not validate the request.

Use:

const result =
  registerSchema
    .safeParse(
      request.body
    );

Mistake 2: Sharing inappropriate rules

The frontend can share format rules.

It cannot safely decide:

Email is unique
User is authorized
Token is valid
Session is active

Those require trusted server-side checks.

Mistake 3: Putting every business rule in one schema

Schemas are useful, but a 1,000-line schema can become difficult to maintain.

Separate:

Structural validation
Business services
Database constraints
Authorization

Mistake 4: Ignoring transformed types

A schema can transform:

string input
    ↓
Date output

or:

string input
    ↓
number output

In those cases:

z.input<typeof schema>

and:

z.output<typeof schema>

may be different.

Interview questions

Why do we need runtime validation when we already have TypeScript?

TypeScript checks source code during development.

It cannot guarantee that runtime values from forms, APIs, files, databases, or external services match the expected types.

Runtime schemas validate actual values.

What is the difference between parse() and safeParse() in Zod?

parse() returns validated data or throws an error.

safeParse() returns a success-or-failure result object.

Can the same validation schema be used on the frontend and backend?

Yes, structural and format validation can often be shared.

The backend must still enforce trusted business rules such as uniqueness, authorization, token validity, and database constraints.

Why this evolved

Validation libraries made rules reusable and type-safe, but React Hook Form still needed a way to understand their success and error formats. Resolver integrations were created to connect schema validation with form state automatically.

Stage 12: React Hook Form with Zod

React Hook Form manages:

  • Inputs
  • Errors
  • Dirty state
  • Touched state
  • Submission state
  • Reset behavior

Zod manages:

  • Validation rules
  • Runtime type safety
  • Transformations
  • Cross-field validation
  • Inferred TypeScript types

The resolver connects them.

React Hook Form
      ↓ values
Zod resolver
      ↓
Zod schema
     / \
 invalid valid
   ↓       ↓
errors    parsed data
   ↓       ↓
formState onSubmit

Install the dependencies

npm install \
  react-hook-form \
  zod \
  @hookform/resolvers

The resolver package officially supports connecting React Hook Form to Zod and inferring schema output types.

Project structure

src/
├── components/
│   └── FormInput.tsx
│
├── features/
│   └── auth/
│       ├── register.api.ts
│       ├── register.schema.ts
│       └── RegisterForm.tsx
│
├── App.tsx
└── index.css

Each file has one responsibility.

register.schema.ts
→ validation contract

register.api.ts
→ network communication

FormInput.tsx
→ reusable presentation

RegisterForm.tsx
→ form behavior and integration

Step 1: Create the Zod schema

Create:

src/features/auth/register.schema.ts
import { z } from "zod";

export const registerSchema =
  z
    .object({
      username:
        z
          .string()
          .trim()
          .min(
            1,
            "Username is required."
          )
          .min(
            3,
            "Username must contain at least 3 characters."
          )
          .max(
            20,
            "Username cannot exceed 20 characters."
          )
          .regex(
            /^[A-Za-z0-9_]+$/,
            "Use only letters, numbers, and underscores."
          ),

      displayName:
        z
          .string()
          .trim()
          .min(
            1,
            "Display name is required."
          )
          .min(
            2,
            "Display name must contain at least 2 characters."
          )
          .max(
            50,
            "Display name cannot exceed 50 characters."
          ),

      email:
        z
          .string()
          .trim()
          .min(
            1,
            "Email is required."
          )
          .email(
            "Enter a valid email address."
          )
          .transform(
            (email) =>
              email.toLowerCase()
          ),

      password:
        z
          .string()
          .min(
            1,
            "Password is required."
          )
          .min(
            8,
            "Password must contain at least 8 characters."
          )
          .max(
            72,
            "Password cannot exceed 72 characters."
          )
          .regex(
            /[A-Z]/,
            "Password must contain an uppercase letter."
          )
          .regex(
            /[a-z]/,
            "Password must contain a lowercase letter."
          )
          .regex(
            /[0-9]/,
            "Password must contain a number."
          ),

      confirmPassword:
        z
          .string()
          .min(
            1,
            "Confirm your password."
          ),

      acceptTerms:
        z
          .boolean()
          .refine(
            (accepted) =>
              accepted,
            {
              message:
                "You must accept the terms.",
            }
          ),
    })
    .refine(
      (values) =>
        values.password ===
        values.confirmPassword,
      {
        path: [
          "confirmPassword",
        ],
        message:
          "Passwords do not match.",
      }
    );

export type RegisterInput =
  z.input<
    typeof registerSchema
  >;

export type RegisterOutput =
  z.output<
    typeof registerSchema
  >;

Why use z.object()?

z.object({
  username: z.string(),
  email: z.string(),
});

z.object() describes the expected shape of the complete form.

Registration object
├── username: string
├── displayName: string
├── email: string
├── password: string
├── confirmPassword: string
└── acceptTerms: boolean

If a field has the wrong runtime type, the schema rejects it.

Why use refine()?

Field-level methods validate one field.

z.string().min(8)

Password confirmation depends on two fields:

values.password ===
values.confirmPassword

That is a cross-field rule.

.refine(
  (values) =>
    values.password ===
    values.confirmPassword,
  {
    path: [
      "confirmPassword",
    ],
    message:
      "Passwords do not match.",
  }
)

The path is essential.

Without it, the issue may belong to the complete object.

With:

path: [
  "confirmPassword",
]

the error is associated with:

errors.confirmPassword

Step 2: Create the API layer

Create:

src/features/auth/register.api.ts
import type {
  RegisterOutput,
} from "./register.schema";

export interface RegisterResponse {
  success: true;
  message: string;
  data: {
    user: {
      id: string;
      username: string;
      displayName: string;
      email: string;
    };
  };
}

export interface ApiErrorResponse {
  success: false;
  message: string;
  field?: keyof RegisterOutput;
}

export class ApiError extends Error {
  status: number;
  data: ApiErrorResponse;

  constructor(
    status: number,
    data: ApiErrorResponse
  ) {
    super(data.message);

    this.name = "ApiError";
    this.status = status;
    this.data = data;
  }
}

export async function registerUser(
  input: RegisterOutput
): Promise<RegisterResponse> {
  // Replace this demonstration with:
  //
  // const response = await fetch(
  //   "/api/auth/register",
  //   {
  //     method: "POST",
  //     headers: {
  //       "Content-Type":
  //         "application/json",
  //     },
  //     body: JSON.stringify(input),
  //   }
  // );

  await new Promise(
    (resolve) => {
      setTimeout(resolve, 1000);
    }
  );

  if (
    input.email ===
    "existing@example.com"
  ) {
    throw new ApiError(
      409,
      {
        success: false,
        field: "email",
        message:
          "An account with this email already exists.",
      }
    );
  }

  if (
    input.username
      .toLowerCase() ===
    "admin"
  ) {
    throw new ApiError(
      409,
      {
        success: false,
        field: "username",
        message:
          "This username is not available.",
      }
    );
  }

  return {
    success: true,
    message:
      "Registration successful. Check your email to verify your account.",

    data: {
      user: {
        id:
          crypto.randomUUID(),

        username:
          input.username,

        displayName:
          input.displayName,

        email:
          input.email,
      },
    },
  };
}

This mock allows the frontend example to run without a backend.

Later, the mock can be replaced with a real Fetch or Axios request.

Step 3: Create a reusable input

Create:

src/components/FormInput.tsx
import {
  forwardRef,
  InputHTMLAttributes,
} from "react";

interface FormInputProps
  extends InputHTMLAttributes<HTMLInputElement> {
  label: string;
  error?: string;
}

const FormInput =
  forwardRef<
    HTMLInputElement,
    FormInputProps
  >(function FormInput(
    {
      id,
      label,
      error,
      className = "",
      ...inputProps
    },
    ref
  ) {
    if (!id) {
      throw new Error(
        "FormInput requires an id."
      );
    }

    const errorId =
      `${id}-error`;

    return (
      <div className="field">
        <label htmlFor={id}>
          {label}
        </label>

        <input
          id={id}
          ref={ref}
          className={
            className
          }
          aria-invalid={Boolean(
            error
          )}
          aria-describedby={
            error
              ? errorId
              : undefined
          }
          {...inputProps}
        />

        {error && (
          <p
            id={errorId}
            className="error"
          >
            {error}
          </p>
        )}
      </div>
    );
  });

export default FormInput;

Why forwardRef() is required

React Hook Form supplies a ref through:

register("email")

Our custom component must pass that ref to the real input.

React Hook Form ref
       ↓
FormInput component
       ↓
forwardRef
       ↓
native <input>

Without forwarding:

<input ref={ref} />

the registration ref would stop at the custom component boundary.

Step 4: Create the registration form

Create:

src/features/auth/RegisterForm.tsx
import {
  SubmitHandler,
  useForm,
} from "react-hook-form";

import {
  zodResolver,
} from "@hookform/resolvers/zod";

import FormInput from "../../components/FormInput";

import {
  ApiError,
  registerUser,
} from "./register.api";

import {
  RegisterInput,
  RegisterOutput,
  registerSchema,
} from "./register.schema";

const defaultValues:
  RegisterInput = {
    username: "",
    displayName: "",
    email: "",
    password: "",
    confirmPassword: "",
    acceptTerms: false,
  };

export default function RegisterForm() {
  const {
    register,
    handleSubmit,
    setError,
    clearErrors,
    reset,
    formState: {
      errors,
      isDirty,
      dirtyFields,
      touchedFields,
      isSubmitting,
      isSubmitSuccessful,
    },
  } =
    useForm<
      RegisterInput,
      unknown,
      RegisterOutput
    >({
      defaultValues,
      resolver:
        zodResolver(
          registerSchema
        ),
      mode: "onBlur",
    });

  const onSubmit:
    SubmitHandler<
      RegisterOutput
    > = async (values) => {
      clearErrors("root.server");

      try {
        const response =
          await registerUser(
            values
          );

        console.log(
          "Registration response:",
          response
        );

        reset();

        alert(
          response.message
        );
      } catch (error) {
        if (
          error instanceof
          ApiError
        ) {
          const {
            field,
            message,
          } = error.data;

          if (
            field &&
            field in
              defaultValues
          ) {
            setError(
              field as keyof RegisterInput,
              {
                type: "server",
                message,
              },
              {
                shouldFocus:
                  true,
              }
            );

            return;
          }

          setError(
            "root.server",
            {
              type: "server",
              message,
            }
          );

          return;
        }

        setError(
          "root.server",
          {
            type: "unknown",
            message:
              "Unable to create your account. Try again.",
          }
        );
      }
    };

  return (
    <form
      className="form"
      onSubmit={
        handleSubmit(onSubmit)
      }
      noValidate
    >
      <header>
        <h1>Create an account</h1>

        <p>
          Learn modern form
          validation with React
          Hook Form and Zod.
        </p>
      </header>

      <FormInput
        id="username"
        label="Username"
        type="text"
        autoComplete="username"
        error={
          errors.username
            ?.message
        }
        {...register(
          "username"
        )}
      />

      <FormInput
        id="displayName"
        label="Display name"
        type="text"
        autoComplete="name"
        error={
          errors.displayName
            ?.message
        }
        {...register(
          "displayName"
        )}
      />

      <FormInput
        id="email"
        label="Email"
        type="email"
        autoComplete="email"
        error={
          errors.email
            ?.message
        }
        {...register(
          "email",
          {
            onChange: () => {
              clearErrors(
                "root.server"
              );
            },
          }
        )}
      />

      <FormInput
        id="password"
        label="Password"
        type="password"
        autoComplete="new-password"
        error={
          errors.password
            ?.message
        }
        {...register(
          "password"
        )}
      />

      <FormInput
        id="confirmPassword"
        label="Confirm password"
        type="password"
        autoComplete="new-password"
        error={
          errors
            .confirmPassword
            ?.message
        }
        {...register(
          "confirmPassword"
        )}
      />

      <div className="field">
        <label className="checkbox">
          <input
            type="checkbox"
            aria-invalid={Boolean(
              errors.acceptTerms
            )}
            aria-describedby={
              errors.acceptTerms
                ? "acceptTerms-error"
                : undefined
            }
            {...register(
              "acceptTerms"
            )}
          />

          <span>
            I accept the terms
            and privacy policy.
          </span>
        </label>

        {errors.acceptTerms && (
          <p
            id="acceptTerms-error"
            className="error"
          >
            {
              errors.acceptTerms
                .message
            }
          </p>
        )}
      </div>

      {errors.root?.server && (
        <p
          className="error alert"
          role="alert"
        >
          {
            errors.root.server
              .message
          }
        </p>
      )}

      {isSubmitSuccessful && (
        <p
          className="success"
          role="status"
        >
          The last submission
          completed successfully.
        </p>
      )}

      <button
        type="submit"
        disabled={isSubmitting}
      >
        {isSubmitting
          ? "Creating account..."
          : "Create account"}
      </button>

      <details>
        <summary>
          Development state
        </summary>

        <pre>
          {JSON.stringify(
            {
              isDirty,
              dirtyFields,
              touchedFields,
            },
            null,
            2
          )}
        </pre>
      </details>
    </form>
  );
}

Step 5: Render the form

Create src/App.tsx:

import RegisterForm from "./features/auth/RegisterForm";

export default function App() {
  return (
    <main className="page">
      <RegisterForm />
    </main>
  );
}

Step 6: Add styles

Create or update src/index.css:

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  background: #f4f4f5;
  color: #18181b;
  font-family:
    Inter,
    Arial,
    sans-serif;
}

button,
input {
  font: inherit;
}

.page {
  min-height: 100vh;
  display: grid;
  place-items: center;
  padding: 24px;
}

.form {
  width: min(100%, 500px);
  display: grid;
  gap: 18px;
  padding: 28px;
  background: white;
  border-radius: 14px;
  box-shadow:
    0 10px 30px
    rgb(0 0 0 / 8%);
}

.form header {
  display: grid;
  gap: 6px;
}

.form h1,
.form p {
  margin: 0;
}

.field {
  display: grid;
  gap: 6px;
}

input {
  width: 100%;
  padding: 10px 12px;
  border: 1px solid #71717a;
  border-radius: 6px;
}

input:focus {
  outline:
    3px solid
    rgb(59 130 246 / 25%);
  border-color: #2563eb;
}

input[aria-invalid="true"] {
  border-color: #b91c1c;
}

.checkbox {
  display: flex;
  align-items: flex-start;
  gap: 10px;
}

.checkbox input {
  width: auto;
  margin-top: 4px;
}

button {
  padding: 11px 16px;
  border: 0;
  border-radius: 6px;
  background: #18181b;
  color: white;
  cursor: pointer;
}

button:disabled {
  cursor: not-allowed;
  opacity: 0.65;
}

.error {
  color: #b91c1c;
  font-size: 14px;
}

.success {
  color: #15803d;
}

.alert {
  padding: 10px;
  border:
    1px solid
    #b91c1c;
  border-radius: 6px;
  background: #fef2f2;
}

details {
  padding-top: 8px;
}

pre {
  max-width: 100%;
  overflow: auto;
  padding: 12px;
  background: #f4f4f5;
  border-radius: 6px;
  font-size: 12px;
}

Understanding the useForm generic types

We used:

useForm<
  RegisterInput,
  unknown,
  RegisterOutput
>()

The three generic positions represent:

useForm<
  Input,
  Context,
  Output
>

The resolver documentation shows this input/context/output form for schemas that transform data.

In our schema:

type RegisterInput =
  z.input<
    typeof registerSchema
  >;

describes values before schema parsing.

type RegisterOutput =
  z.output<
    typeof registerSchema
  >;

describes values after parsing and transformations.

Our email transformation changes:

KARTHIK@EXAMPLE.COM
        ↓
karthik@example.com

The TypeScript type remains a string, but the semantic value is normalized.

For schemas that convert strings into dates or numbers, the input and output TypeScript types may also differ.

What does zodResolver() do?

We configure:

resolver:
  zodResolver(
    registerSchema
  )

When the form validates, the resolver conceptually performs:

const result =
  registerSchema
    .safeParse(values);

If successful:

{
  values: result.data,
  errors: {}
}

If unsuccessful, it maps Zod issues into field errors:

{
  values: {},
  errors: {
    email: {
      type:
        "invalid_format",
      message:
        "Enter a valid email address."
    }
  }
}

The exact internal representation should be treated as library implementation detail, but this is the important public flow.

Complete validation data flow

User types into email input
             ↓
register("email") tracks field
             ↓
User blurs field or submits form
             ↓
React Hook Form collects values
             ↓
zodResolver receives values
             ↓
registerSchema validates values
          /             \
       Invalid           Valid
          ↓                ↓
Zod returns issues    Zod returns parsed data
          ↓                ↓
Resolver maps issues  handleSubmit calls
to RHF errors         onSubmit(parsedData)
          ↓                ↓
errors.email          API function runs
          ↓
Error rendered under email

How one error reaches only one input

Suppose Zod produces an issue at:

path: [
  "email",
]

The resolver converts it into:

errors.email

Our component passes:

error={
  errors.email?.message
}

only to the email input:

<FormInput
  id="email"
  error={
    errors.email?.message
  }
/>

The username input reads:

errors.username

The password input reads:

errors.password

Therefore:

errors.email
     ↓
Email FormInput
     ↓
email-error paragraph

It does not automatically appear under every input.

Each component explicitly reads the error path belonging to its own field.

Cross-field error flow

The schema contains:

.refine(
  (values) =>
    values.password ===
    values.confirmPassword,
  {
    path: [
      "confirmPassword",
    ],
    message:
      "Passwords do not match.",
  }
)

The path produces:

errors.confirmPassword

Then:

<FormInput
  error={
    errors.confirmPassword
      ?.message
  }
/>

displays it only under the confirmation field.

Cross-field comparison fails
             ↓
Zod issue path:
confirmPassword
             ↓
errors.confirmPassword
             ↓
Confirmation input error

Frontend validation is not security

The React form can be bypassed.

An attacker can directly send:

POST /api/auth/register
Content-Type: application/json

{
  "username": "",
  "email": "invalid",
  "password": "1"
}

Therefore, the backend must also validate the body.

const result =
  registerSchema
    .safeParse(
      request.body
    );

if (!result.success) {
  return response
    .status(400)
    .json({
      success: false,
      message:
        "Validation failed.",
      errors:
        result.error
          .flatten()
          .fieldErrors,
    });
}

The frontend provides fast feedback.

The backend enforces correctness.

Accessibility review

Our reusable input connects:

<label htmlFor={id}>

to:

<input id={id} />

When an error exists:

aria-invalid={true}

and:

aria-describedby={
  `${id}-error`
}

connect the field to:

<p id={`${id}-error`}>

The relationship is:

Input
aria-describedby="email-error"
              ↓
Error paragraph
id="email-error"

The form also uses:

role="alert"

for a root server error.

Performance notes

Using Zod does not mean validation is free.

Validation still performs work.

Be deliberate about:

mode: "onChange"

for large schemas because it can validate frequently.

A common balanced configuration is:

mode: "onBlur"

or the default submit-first approach.

For expensive asynchronous checks such as username availability:

  • Debounce requests
  • Cancel outdated requests
  • Avoid calling the backend on every raw keystroke
  • Validate format locally first
  • Perform the authoritative check on submission

Common React Hook Form + Zod mistakes

Mistake 1: Defining the type separately

Avoid duplicating:

interface RegisterInput {
  username: string;
  email: string;
}

and:

const registerSchema =
  z.object({
    username: z.string(),
    email: z.string(),
  });

Prefer inference:

type RegisterInput =
  z.input<
    typeof registerSchema
  >;

Mistake 2: Forgetting the resolver

Defining a schema does not automatically connect it to the form.

This:

const schema =
  z.object({
    email: z.string(),
  });

does nothing by itself.

Connect it:

useForm({
  resolver:
    zodResolver(schema),
});

Mistake 3: Adding duplicate rules

Avoid unnecessarily validating the same field in both:

register("email", {
  required:
    "Email is required.",
})

and:

z.string().min(
  1,
  "Email is required."
)

When using a resolver, keep the primary validation contract in the schema unless a field-specific registration rule has a deliberate purpose.

Mistake 4: Forgetting the cross-field path

Bad:

.refine(
  passwordsMatch,
  {
    message:
      "Passwords do not match.",
  }
)

The error may be treated as an object-level issue.

Better:

.refine(
  passwordsMatch,
  {
    path: [
      "confirmPassword",
    ],
    message:
      "Passwords do not match.",
  }
)

Mistake 5: Trimming passwords automatically

This may be dangerous:

password:
  z
    .string()
    .trim()

A user’s password may intentionally contain leading or trailing spaces.

Normalize usernames and emails deliberately.

Do not silently transform passwords unless the product explicitly defines that behavior.

Mistake 6: Expecting Zod to check the database

Zod can validate:

Email has a valid format

It cannot independently know:

Email already exists

That requires a database query.

The backend returns the result, and React Hook Form can inject it using:

setError("email", {
  type: "server",
  message:
    "Email already exists.",
});

Senior engineer tips

Keep schemas outside components

Better:

register.schema.ts
RegisterForm.tsx

Avoid redefining a large schema every time the component function runs.

Separate input and output types when transformations exist

type Input =
  z.input<typeof schema>;

type Output =
  z.output<typeof schema>;

This becomes important when converting:

"21"
 ↓
21

or:

"2026-08-24"
      ↓
Date object

Use schemas at trust boundaries

Good validation boundaries include:

Form submission
API request body
Environment variables
External API response
File contents
Queue messages
Database JSON fields

Keep the API layer separate

Avoid placing every Fetch detail inside the form component.

Better:

RegisterForm
    ↓
registerUser()
    ↓
HTTP request

The form handles interface behavior.

The API function handles communication.

Interview questions

What is a resolver in React Hook Form?

A resolver adapts the result of an external validation library into React Hook Form’s expected values-and-errors format.

Why combine React Hook Form and Zod?

React Hook Form manages interactive form state and field registration.

Zod manages runtime data validation, transformations, cross-field rules, and inferred TypeScript types.

How does a Zod error appear in errors.email?

Zod produces an issue whose path contains email.

The Zod resolver maps that issue to React Hook Form’s nested error object under the same field path.

What is the difference between z.input and z.output?

z.input represents the value accepted before schema parsing and transformations.

z.output represents the validated value returned after parsing and transformations.

Should the backend validate again when the frontend uses Zod?

Yes.

Frontend code can be bypassed. The backend must validate every untrusted request independently.

Part 3 summary

React Hook Form changed the form architecture from:

Every keystroke
    ↓
Parent React state
    ↓
Complete form component render

toward:

Native input stores value
    ↓
register connects field
    ↓
Form control tracks state
    ↓
Subscribed UI updates

Its major APIs can be organized as:

Create
└── useForm

Connect
├── register
└── Controller

Read
├── watch
└── getValues

Write
└── setValue

Validate
├── handleSubmit
├── trigger
├── setError
└── clearErrors

Reset
├── reset
└── resetField

Observe
└── formState

Validation libraries then moved rules from scattered conditions into reusable schemas.

Manual conditions
        ↓
Schema validation
        ↓
Runtime safety
        ↓
Type inference
        ↓
Shared validation contracts

Finally, React Hook Form and Zod combined their responsibilities:

React Hook Form
→ form behavior

Zod
→ validation contract

zodResolver
→ connection between them

The next part will cover:

Stage 13
Server-side validation with Express

Stage 14
Mapping backend errors with setError()

Stage 15
Accessible production forms

Stage 16
Form performance and render comparisons
DE
Source

This article was originally published by DEV Community and written by Karthik Reddy.

Read original article on DEV Community
Back to Discover

Reading List