DEV Community

Karthik Reddy
Karthik Reddy

Posted on

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"
);
Enter fullscreen mode Exit fullscreen mode

React allowed us to describe the interface from state:

<input
  aria-invalid={Boolean(errors.email)}
/>

{errors.email && (
  <p>{errors.email}</p>
)}
Enter fullscreen mode Exit fullscreen mode

However, React did not automatically manage:

  • Form values
  • Validation errors
  • Touched fields
  • Dirty fields
  • Submission state
  • Reset behavior
  • Dynamic fields
  • Backend errors
  • Performance

Developers still had to build those features manually.

That created the need for form-management libraries.

This part covers:

  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
Enter fullscreen mode Exit fullscreen mode

Stage 9: React Hook Form Deep Dive

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

It uses a different architectural philosophy.

A traditional controlled input stores its value in React state:

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

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

Every keystroke produces a state update:

User types
    ↓
onChange runs
    ↓
setEmail runs
    ↓
Component renders again
    ↓
Input receives the new value
Enter fullscreen mode Exit fullscreen mode

React Hook Form prefers native, uncontrolled inputs when possible.

<input
  {...register("email")}
/>
Enter fullscreen mode Exit fullscreen mode

The browser stores the current value inside the input element.

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


Controlled versus uncontrolled inputs

Controlled input

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

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

React owns the value.

React state
    ↓
input value
Enter fullscreen mode Exit fullscreen mode

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


Uncontrolled input

<input
  name="email"
  defaultValue=""
/>
Enter fullscreen mode Exit fullscreen mode

The browser owns the current value.

DOM input element
    ↓
current value
Enter fullscreen mode Exit fullscreen mode

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

The current value can be read through:

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

A native uncontrolled input using useRef

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

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

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

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

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

    console.log({ email });
  }

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

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

      <button type="submit">
        Submit
      </button>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

Notice what is missing:

value={email}
onChange={handleChange}
Enter fullscreen mode Exit fullscreen mode

The browser stores the entered value.

On submission, we access the element through:

emailRef.current
Enter fullscreen mode Exit fullscreen mode

and read:

emailRef.current.value
Enter fullscreen mode Exit fullscreen mode

What is a ref?

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

const emailRef =
  useRef<HTMLInputElement>(null);
Enter fullscreen mode Exit fullscreen mode

After React connects the ref to the input:

<input ref={emailRef} />
Enter fullscreen mode Exit fullscreen mode

the ref may contain the actual DOM element:

emailRef.current
        ↓
HTMLInputElement
Enter fullscreen mode Exit fullscreen mode

We can then access browser properties:

emailRef.current?.value;
emailRef.current?.focus();
emailRef.current?.disabled;
emailRef.current?.files;
Enter fullscreen mode Exit fullscreen mode

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


What does register() return?

Consider:

const {
  register,
} = useForm();

const registration =
  register("email");

console.log(registration);
Enter fullscreen mode Exit fullscreen mode

Conceptually, the returned object resembles:

{
  name: "email",
  onChange: function,
  onBlur: function,
  ref: function
}
Enter fullscreen mode Exit fullscreen mode

When we write:

<input
  {...register("email")}
/>
Enter fullscreen mode Exit fullscreen mode

the spread operator applies those properties to the input.

Conceptually, it becomes:

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

React Hook Form can now:

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

Why the field name matters

register("email")
Enter fullscreen mode Exit fullscreen mode

registers a field under the key:

email
Enter fullscreen mode Exit fullscreen mode

The submitted object becomes:

{
  email: "karthik@example.com"
}
Enter fullscreen mode Exit fullscreen mode

A nested field name:

register("address.city")
Enter fullscreen mode Exit fullscreen mode

can produce:

{
  address: {
    city: "Kadapa"
  }
}
Enter fullscreen mode Exit fullscreen mode

An array-style field:

register(
  "experiences.0.company"
)
Enter fullscreen mode Exit fullscreen mode

can produce:

{
  experiences: [
    {
      company: "Example Company"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

The name is not merely an HTML detail.

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


React Hook Form’s conceptual architecture

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

However, the public architecture can be understood conceptually.

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

When an input changes:

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

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


Why fewer rerenders can happen

In a manually controlled form:

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

With React Hook Form and a native registered input:

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

This does not mean React Hook Form never rerenders.

Rerenders can still happen when:

  • An error appears or disappears
  • 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();
Enter fullscreen mode Exit fullscreen mode

The component is interested in errors.

If it also reads:

const {
  formState: {
    errors,
    isDirty,
    isSubmitting,
  },
} = useForm();
Enter fullscreen mode Exit fullscreen mode

it is interested in three pieces of form state.

Conceptually:

Component subscribes to:
├── errors
├── isDirty
└── isSubmitting
Enter fullscreen mode Exit fullscreen mode

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


React Hook Form does not eliminate state

A common misunderstanding is:

React Hook Form does not use state.

That is not accurate.

React Hook Form still manages state such as:

errors
dirty fields
touched fields
submission state
validation state
registered fields
default values
Enter fullscreen mode Exit fullscreen mode

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


Complete React Hook Form example

Install React Hook Form:

npm install react-hook-form
Enter fullscreen mode Exit fullscreen mode

Create src/App.tsx:

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

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

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

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

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

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

      reset();
    };

  console.log(
    "Registration form rendered"
  );

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Add src/index.css:

* {
  box-sizing: border-box;
}

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

button,
input {
  font: inherit;
}

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

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

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

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

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

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

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

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

button:disabled {
  cursor: not-allowed;
  opacity: 0.65;
}
Enter fullscreen mode Exit fullscreen mode

Controlled React versus React Hook Form

Controlled form

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

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

React Hook Form

<input
  {...register("email")}
/>
Enter fullscreen mode Exit fullscreen mode

Comparison

Concern Controlled React React Hook Form
Current native input value React state Usually the DOM input
Change handler Manually written Supplied 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}
/>
Enter fullscreen mode Exit fullscreen mode

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

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

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

Or explicitly compose the handlers.


Common beginner mistake: losing the ref

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

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


Interview question

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

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

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


Why this evolved

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


Stage 10: React Hook Form APIs

React Hook Form exposes many methods.

Do not try to memorize all of them at once.

Instead, organize them by responsibility.

Form creation
└── useForm

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

Submission
└── handleSubmit

Read values
├── watch
└── getValues

Change values
└── setValue

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

Reset state
├── reset
└── resetField

Form status
└── formState
Enter fullscreen mode Exit fullscreen mode

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


1. useForm()

useForm() creates the form-control system.

const form =
  useForm<RegistrationValues>();
Enter fullscreen mode Exit fullscreen mode

Most applications destructure the required methods:

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

Important useForm() options

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

  mode: "onBlur",

  shouldUnregister: false,
});
Enter fullscreen mode Exit fullscreen mode

Important options include:

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

2. defaultValues

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

useForm({
  defaultValues,
});
Enter fullscreen mode Exit fullscreen mode

Default values serve as the baseline for:

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

Current email:
"karthik@example.com"

isDirty:
true
Enter fullscreen mode Exit fullscreen mode

If the current value returns to the default:

Current email:
""

isDirty:
false
Enter fullscreen mode Exit fullscreen mode

Use consistent values.

For text inputs, prefer:

email: ""
Enter fullscreen mode Exit fullscreen mode

instead of:

email: undefined
Enter fullscreen mode Exit fullscreen mode

Asynchronous default values

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

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

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

    return response.json();
  },
});
Enter fullscreen mode Exit fullscreen mode

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


3. mode

mode controls when initial validation runs.

useForm({
  mode: "onSubmit",
});
Enter fullscreen mode Exit fullscreen mode

Common modes include:

Mode Validation 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",
});
Enter fullscreen mode Exit fullscreen mode

This provides a balanced experience:

User types
    ↓
No immediate interruption
    ↓
User leaves field
    ↓
Validate field
Enter fullscreen mode Exit fullscreen mode

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


4. shouldUnregister

Imagine a conditional field:

{hasCompany && (
  <input
    {...register(
      "companyName"
    )}
  />
)}
Enter fullscreen mode Exit fullscreen mode

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

With the default preservation-oriented behavior:

shouldUnregister: false
Enter fullscreen mode Exit fullscreen mode

an unmounted field can remain represented in form data.

With:

shouldUnregister: true
Enter fullscreen mode Exit fullscreen mode

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

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

Use false when temporarily hidden steps should preserve their data.


5. register()

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

<input
  {...register("email")}
/>
Enter fullscreen mode Exit fullscreen mode

It can also accept validation rules:

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

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

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

Common registration rules include:

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

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

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

  valueAsNumber: true,

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

Custom validation with validate

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

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

A validation function can return:

true
Enter fullscreen mode Exit fullscreen mode

when valid, or:

an error message
Enter fullscreen mode Exit fullscreen mode

when invalid.


6. handleSubmit()

handleSubmit() coordinates validation and submission.

<form
  onSubmit={
    handleSubmit(onSubmit)
  }
>
Enter fullscreen mode Exit fullscreen mode

The success callback receives validated values:

const onSubmit = (
  values: RegistrationValues
) => {
  console.log(values);
};
Enter fullscreen mode Exit fullscreen mode

You can also provide an invalid callback:

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

<form
  onSubmit={
    handleSubmit(
      onSubmit,
      onInvalid
    )
  }
/>
Enter fullscreen mode Exit fullscreen mode

The process is:

Native submit event
       ↓
handleSubmit
       ↓
Run validation
      / \
   Invalid Valid
      ↓     ↓
 onInvalid onSubmit
Enter fullscreen mode Exit fullscreen mode

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


7. watch()

watch() subscribes to value changes.

const country =
  watch("country");
Enter fullscreen mode Exit fullscreen mode

You can use the value for conditional rendering:

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

Watch multiple fields:

const [
  password,
  confirmPassword,
] = watch([
  "password",
  "confirmPassword",
]);
Enter fullscreen mode Exit fullscreen mode

Watch the entire form:

const values = watch();
Enter fullscreen mode Exit fullscreen mode

Be careful with broad watches.

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

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


watch() versus getValues()

These methods can return similar data but have different purposes.

const email =
  watch("email");
Enter fullscreen mode Exit fullscreen mode

watch() subscribes to changes.

const email =
  getValues("email");
Enter fullscreen mode Exit fullscreen mode

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

Use:

watch()
Enter fullscreen mode Exit fullscreen mode

when the UI must react to changes.

Use:

getValues()
Enter fullscreen mode Exit fullscreen mode

when you only need a value at a particular moment.


8. getValues()

Read every field:

const values =
  getValues();
Enter fullscreen mode Exit fullscreen mode

Read one field:

const email =
  getValues("email");
Enter fullscreen mode Exit fullscreen mode

Read several fields:

const [
  email,
  username,
] = getValues([
  "email",
  "username",
]);
Enter fullscreen mode Exit fullscreen mode

Example:

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

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

9. setValue()

setValue() updates a field programmatically.

setValue(
  "email",
  "karthik@example.com"
);
Enter fullscreen mode Exit fullscreen mode

Options can update related form state:

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

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


Production examples for setValue()

Selecting an address

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

OCR document extraction

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

Choosing a suggested username

setValue(
  "username",
  suggestedUsername,
  {
    shouldDirty: true,
    shouldValidate: true,
  }
);
Enter fullscreen mode Exit fullscreen mode

10. setError()

setError() manually inserts an error.

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

The result becomes available through:

errors.email
Enter fullscreen mode Exit fullscreen mode

You can then render:

{errors.email && (
  <p>
    {errors.email.message}
  </p>
)}
Enter fullscreen mode Exit fullscreen mode

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


Root-level errors

Not every error belongs to one field.

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

Display it:

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

Examples of root errors:

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

11. clearErrors()

Clear one error:

clearErrors("email");
Enter fullscreen mode Exit fullscreen mode

Clear multiple errors:

clearErrors([
  "email",
  "username",
]);
Enter fullscreen mode Exit fullscreen mode

Clear all errors:

clearErrors();
Enter fullscreen mode Exit fullscreen mode

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

Example:

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

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

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

The value should still be revalidated when appropriate.


12. reset()

Reset the complete form:

reset();
Enter fullscreen mode Exit fullscreen mode

Reset with new values:

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

This can be useful after fetching existing data:

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

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


Reset while preserving selected state

reset(
  {
    username: "",
    email: "",
    password: "",
  },
  {
    keepErrors: true,
    keepDirty: true,
  }
);
Enter fullscreen mode Exit fullscreen mode

Use preservation options carefully.

After a successful registration, the common behavior is:

reset();
Enter fullscreen mode Exit fullscreen mode

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


13. resetField()

Reset only one field:

resetField("email");
Enter fullscreen mode Exit fullscreen mode

Reset to a new default value:

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

Optionally preserve state:

resetField("email", {
  keepError: true,
  keepDirty: true,
  keepTouched: true,
});
Enter fullscreen mode Exit fullscreen mode

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


14. trigger()

trigger() manually runs validation.

Validate the entire form:

const isValid =
  await trigger();
Enter fullscreen mode Exit fullscreen mode

Validate one field:

const emailIsValid =
  await trigger("email");
Enter fullscreen mode Exit fullscreen mode

Validate several fields:

const credentialsAreValid =
  await trigger([
    "email",
    "password",
  ]);
Enter fullscreen mode Exit fullscreen mode

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


Multi-step form example

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

  if (!stepIsValid) {
    return;
  }

  setCurrentStep(2);
}
Enter fullscreen mode Exit fullscreen mode

Only move forward when the current step is valid.


15. control

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

const {
  control,
} = useForm();
Enter fullscreen mode Exit fullscreen mode

You normally pass it to:

  • 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}
/>
Enter fullscreen mode Exit fullscreen mode

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

Controller connects these components to React Hook Form.

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

Controller supplies:

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

fieldState
├── error
├── invalid
├── isDirty
└── isTouched
Enter fullscreen mode Exit fullscreen mode

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


Important correction: isTouched versus touchedFields

There is no commonly used top-level:

formState.isTouched
Enter fullscreen mode Exit fullscreen mode

Instead, React Hook Form provides:

formState.touchedFields
Enter fullscreen mode Exit fullscreen mode

for the collection of touched fields.

For one controlled field, Controller provides:

fieldState.isTouched
Enter fullscreen mode Exit fullscreen mode

Conceptually:

Whole form:
touchedFields.email

One Controller field:
fieldState.isTouched
Enter fullscreen mode Exit fullscreen mode

17. resolver

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

useForm({
  resolver:
    zodResolver(schema),
});
Enter fullscreen mode Exit fullscreen mode

The resolver:

  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();
Enter fullscreen mode Exit fullscreen mode

Common properties include:

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

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


errors

errors.email
Enter fullscreen mode Exit fullscreen mode

Possible shape:

{
  type: "required",
  message:
    "Email is required.",
  ref: HTMLInputElement
}
Enter fullscreen mode Exit fullscreen mode

Render:

{errors.email && (
  <p>
    {errors.email.message}
  </p>
)}
Enter fullscreen mode Exit fullscreen mode

isDirty

isDirty
Enter fullscreen mode Exit fullscreen mode

Indicates whether the form currently differs from its default values.

<button
  disabled={!isDirty}
>
  Save changes
</button>
Enter fullscreen mode Exit fullscreen mode

dirtyFields

dirtyFields
Enter fullscreen mode Exit fullscreen mode

Identifies individual changed fields.

Example:

{
  email: true,
  address: {
    city: true
  }
}
Enter fullscreen mode Exit fullscreen mode

Use it for:

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

touchedFields

touchedFields
Enter fullscreen mode Exit fullscreen mode

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

Example:

{
  email: true,
  password: true
}
Enter fullscreen mode Exit fullscreen mode

isSubmitting

isSubmitting
Enter fullscreen mode Exit fullscreen mode

Becomes true while an asynchronous submit callback is running.

<button
  disabled={isSubmitting}
>
  {isSubmitting
    ? "Saving..."
    : "Save"}
</button>
Enter fullscreen mode Exit fullscreen mode

isValid

isValid
Enter fullscreen mode Exit fullscreen mode

Represents whether the form currently satisfies its configured validation rules.

Its usefulness depends on the chosen validation mode.

For example:

useForm({
  mode: "onChange",
});
Enter fullscreen mode Exit fullscreen mode

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


defaultValues

The configured default values are also available through form state.

formState.defaultValues
Enter fullscreen mode Exit fullscreen mode

This can be useful when comparing or displaying original values.


Complete API playground

The following example demonstrates:

  • 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

Common React Hook Form mistakes

Mistake 1: Using both register() and Controller

Bad:

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

The field is registered twice.

Use either:

register("email")
Enter fullscreen mode Exit fullscreen mode

or:

<Controller
  name="email"
  control={control}
/>
Enter fullscreen mode Exit fullscreen mode

for that integration.


Mistake 2: Using Controller for every native input

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

For a standard input, prefer:

<input
  {...register("email")}
/>
Enter fullscreen mode Exit fullscreen mode

Use Controller when the component genuinely needs controlled integration.


Mistake 3: Calling watch() for everything

const values = watch();
Enter fullscreen mode Exit fullscreen mode

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

Watch only what the UI requires:

const country =
  watch("country");
Enter fullscreen mode Exit fullscreen mode

Mistake 4: Omitting default values

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

Prefer:

useForm({
  defaultValues: {
    email: "",
    password: "",
  },
});
Enter fullscreen mode Exit fullscreen mode

Mistake 5: Treating getValues() as reactive

This does not subscribe:

const email =
  getValues("email");
Enter fullscreen mode Exit fullscreen mode

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

Use:

const email =
  watch("email");
Enter fullscreen mode Exit fullscreen mode

when rendering depends on the current value.


Interview questions

What does register() do?

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

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

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

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

When should you use Controller?

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

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

reset() resets the complete form.

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


Why this evolved

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


Stage 11: Validation Libraries

Validation can be written manually.

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

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

  return undefined;
}
Enter fullscreen mode Exit fullscreen mode

This is reasonable for one field.

But production validation often includes:

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

TypeScript types do not validate runtime data

Consider:

interface User {
  name: string;
  age: number;
}
Enter fullscreen mode Exit fullscreen mode

This helps while writing TypeScript.

But an API may return:

{
  "name": 123,
  "age": "twenty"
}
Enter fullscreen mode Exit fullscreen mode

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

This assertion:

const user =
  responseData as User;
Enter fullscreen mode Exit fullscreen mode

does not validate anything.

It only tells TypeScript:

Trust me. Treat this as User.
Enter fullscreen mode Exit fullscreen mode

A runtime validation library actually checks the value.


Manual validation

Complete manual schema-like validator

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

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

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

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

type ValidationResult =
  | ValidationSuccess
  | ValidationFailure;

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

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

  const errors:
    RegistrationErrors = {};

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

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

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

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

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

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

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

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

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

  return {
    success: true,
    data: {
      username,
      email,
      password,
      confirmPassword,
    },
  };
}
Enter fullscreen mode Exit fullscreen mode

This works.

But we manually implemented:

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

A schema library standardizes this work.


Yup

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

Install:

npm install yup
Enter fullscreen mode Exit fullscreen mode

Example:

import * as yup from "yup";

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

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

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

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

type RegistrationInput =
  yup.InferType<
    typeof registrationSchema
  >;
Enter fullscreen mode Exit fullscreen mode

Validate:

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

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

Yup advantages

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

Yup disadvantages

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

Zod

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

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

Install:

npm install zod
Enter fullscreen mode Exit fullscreen mode

Example:

import { z } from "zod";

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

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

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

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

type RegistrationInput =
  z.input<
    typeof registrationSchema
  >;

type RegistrationOutput =
  z.output<
    typeof registrationSchema
  >;
Enter fullscreen mode Exit fullscreen mode

parse()

const validated =
  registrationSchema.parse(
    input
  );
Enter fullscreen mode Exit fullscreen mode

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


safeParse()

const result =
  registrationSchema
    .safeParse(input);

if (!result.success) {
  console.log(
    result.error
  );
} else {
  console.log(
    result.data
  );
}
Enter fullscreen mode Exit fullscreen mode

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

Validation result
      /       \
 success      failure
    ↓            ↓
 result.data  result.error
Enter fullscreen mode Exit fullscreen mode

Zod advantages

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

Zod disadvantages

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

Valibot

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

Install:

npm install valibot
Enter fullscreen mode Exit fullscreen mode

Example:

import * as v from "valibot";

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

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

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

      confirmPassword:
        v.string(),
    }),

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

type RegistrationInput =
  v.InferInput<
    typeof registrationSchema
  >;

type RegistrationOutput =
  v.InferOutput<
    typeof registrationSchema
  >;
Enter fullscreen mode Exit fullscreen mode

Validate:

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

if (result.success) {
  console.log(
    result.output
  );
} else {
  console.log(
    result.issues
  );
}
Enter fullscreen mode Exit fullscreen mode

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


Valibot advantages

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

Valibot disadvantages

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

Vest

Vest takes inspiration from unit-test syntax.

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

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

Install:

npm install vest
Enter fullscreen mode Exit fullscreen mode

Example:

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

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

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

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

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

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

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

Run:

const result =
  registrationSuite.run(
    formData
  );

if (
  result.hasErrors(
    "email"
  )
) {
  console.log(
    result.getError(
      "email"
    )
  );
}
Enter fullscreen mode Exit fullscreen mode

Vest is especially relevant for:

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

Vest advantages

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

Vest disadvantages

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

Validation-library comparison

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

Example:

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

type User =
  z.infer<
    typeof userSchema
  >;
Enter fullscreen mode Exit fullscreen mode

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

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

const userSchema = {
  // Duplicate definition
};
Enter fullscreen mode Exit fullscreen mode

Duplicated definitions can drift apart.

With inference:

Schema
  ├── validates runtime data
  └── generates TypeScript type
Enter fullscreen mode Exit fullscreen mode

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


Sharing frontend and backend schemas

A monorepo may contain:

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

packages/
└── validation/
    └── auth.schema.ts
Enter fullscreen mode Exit fullscreen mode

Shared schema:

import { z } from "zod";

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

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

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

export type RegisterInput =
  z.input<
    typeof registerSchema
  >;
Enter fullscreen mode Exit fullscreen mode

Frontend:

resolver:
  zodResolver(
    registerSchema
  )
Enter fullscreen mode Exit fullscreen mode

Backend:

const result =
  registerSchema.safeParse(
    request.body
  );
Enter fullscreen mode Exit fullscreen mode

This reduces accidental rule differences.

However, not every rule should be shared.

The backend may also enforce:

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

Complete Zod validation example

Create validation-demo.ts:

import { z } from "zod";

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

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

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

      confirmPassword:
        z.string(),

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

type RegistrationInput =
  z.input<
    typeof registrationSchema
  >;

type RegistrationOutput =
  z.output<
    typeof registrationSchema
  >;

const input:
  RegistrationInput = {
    username: "  Karthik_2005  ",
    email:
      "  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
  );
}
Enter fullscreen mode Exit fullscreen mode

Output:

{
  username:
    "Karthik_2005",

  email:
    "karthik@example.com",

  password:
    "Password123",

  confirmPassword:
    "Password123",

  acceptTerms:
    true
}
Enter fullscreen mode Exit fullscreen mode

The schema did more than validate.

It also transformed selected values:

"  KARTHIK@EXAMPLE.COM  "
              ↓
"karthik@example.com"
Enter fullscreen mode Exit fullscreen mode

Common validation mistakes

Mistake 1: Using TypeScript assertions as validation

const data =
  request.body as RegisterInput;
Enter fullscreen mode Exit fullscreen mode

This does not validate the request.

Use:

const result =
  registerSchema
    .safeParse(
      request.body
    );
Enter fullscreen mode Exit fullscreen mode

Mistake 2: Sharing inappropriate rules

The frontend can share format rules.

It cannot safely decide:

Email is unique
User is authorized
Token is valid
Session is active
Enter fullscreen mode Exit fullscreen mode

Those require trusted server-side checks.


Mistake 3: Putting every business rule in one schema

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

Separate:

Structural validation
Business services
Database constraints
Authorization
Enter fullscreen mode Exit fullscreen mode

Mistake 4: Ignoring transformed types

A schema can transform:

string input
    ↓
Date output
Enter fullscreen mode Exit fullscreen mode

or:

string input
    ↓
number output
Enter fullscreen mode Exit fullscreen mode

In those cases:

z.input<typeof schema>
Enter fullscreen mode Exit fullscreen mode

and:

z.output<typeof schema>
Enter fullscreen mode Exit fullscreen mode

may be different.


Interview questions

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

TypeScript checks source code during development.

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

Runtime schemas validate actual values.

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

parse() returns validated data or throws an error.

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

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

Yes, structural and format validation can often be shared.

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


Why this evolved

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


Stage 12: React Hook Form with Zod

React Hook Form manages:

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

Zod manages:

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

The resolver connects them.

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

Install the dependencies

npm install \
  react-hook-form \
  zod \
  @hookform/resolvers
Enter fullscreen mode Exit fullscreen mode

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


Project structure

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

Each file has one responsibility.

register.schema.ts
→ validation contract

register.api.ts
→ network communication

FormInput.tsx
→ reusable presentation

RegisterForm.tsx
→ form behavior and integration
Enter fullscreen mode Exit fullscreen mode

Step 1: Create the Zod schema

Create:

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

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

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

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

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

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

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

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

export type RegisterOutput =
  z.output<
    typeof registerSchema
  >;
Enter fullscreen mode Exit fullscreen mode

Why use z.object()?

z.object({
  username: z.string(),
  email: z.string(),
});
Enter fullscreen mode Exit fullscreen mode

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

Registration object
├── username: string
├── displayName: string
├── email: string
├── password: string
├── confirmPassword: string
└── acceptTerms: boolean
Enter fullscreen mode Exit fullscreen mode

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


Why use refine()?

Field-level methods validate one field.

z.string().min(8)
Enter fullscreen mode Exit fullscreen mode

Password confirmation depends on two fields:

values.password ===
values.confirmPassword
Enter fullscreen mode Exit fullscreen mode

That is a cross-field rule.

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

The path is essential.

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

With:

path: [
  "confirmPassword",
]
Enter fullscreen mode Exit fullscreen mode

the error is associated with:

errors.confirmPassword
Enter fullscreen mode Exit fullscreen mode

Step 2: Create the API layer

Create:

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

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

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

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

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

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

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

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

  if (
    input.email ===
    "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,
      },
    },
  };
}
Enter fullscreen mode Exit fullscreen mode

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

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


Step 3: Create a reusable input

Create:

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

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

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

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

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

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

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

export default FormInput;
Enter fullscreen mode Exit fullscreen mode

Why forwardRef() is required

React Hook Form supplies a ref through:

register("email")
Enter fullscreen mode Exit fullscreen mode

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

React Hook Form ref
       ↓
FormInput component
       ↓
forwardRef
       ↓
native <input>
Enter fullscreen mode Exit fullscreen mode

Without forwarding:

<input ref={ref} />
Enter fullscreen mode Exit fullscreen mode

the registration ref would stop at the custom component boundary.


Step 4: Create the registration form

Create:

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

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

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

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

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

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

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

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

      try {
        const response =
          await registerUser(
            values
          );

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

        reset();

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

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

            return;
          }

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

          return;
        }

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Step 5: Render the form

Create src/App.tsx:

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

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

Step 6: Add styles

Create or update src/index.css:

* {
  box-sizing: border-box;
}

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

button,
input {
  font: inherit;
}

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

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

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

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

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

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

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

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

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

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

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

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

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

.success {
  color: #15803d;
}

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

details {
  padding-top: 8px;
}

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

Understanding the useForm generic types

We used:

useForm<
  RegisterInput,
  unknown,
  RegisterOutput
>()
Enter fullscreen mode Exit fullscreen mode

The three generic positions represent:

useForm<
  Input,
  Context,
  Output
>
Enter fullscreen mode Exit fullscreen mode

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

In our schema:

type RegisterInput =
  z.input<
    typeof registerSchema
  >;
Enter fullscreen mode Exit fullscreen mode

describes values before schema parsing.

type RegisterOutput =
  z.output<
    typeof registerSchema
  >;
Enter fullscreen mode Exit fullscreen mode

describes values after parsing and transformations.

Our email transformation changes:

KARTHIK@EXAMPLE.COM
        ↓
karthik@example.com
Enter fullscreen mode Exit fullscreen mode

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

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


What does zodResolver() do?

We configure:

resolver:
  zodResolver(
    registerSchema
  )
Enter fullscreen mode Exit fullscreen mode

When the form validates, the resolver conceptually performs:

const result =
  registerSchema
    .safeParse(values);
Enter fullscreen mode Exit fullscreen mode

If successful:

{
  values: result.data,
  errors: {}
}
Enter fullscreen mode Exit fullscreen mode

If unsuccessful, it maps Zod issues into field errors:

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

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


Complete validation data flow

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

How one error reaches only one input

Suppose Zod produces an issue at:

path: [
  "email",
]
Enter fullscreen mode Exit fullscreen mode

The resolver converts it into:

errors.email
Enter fullscreen mode Exit fullscreen mode

Our component passes:

error={
  errors.email?.message
}
Enter fullscreen mode Exit fullscreen mode

only to the email input:

<FormInput
  id="email"
  error={
    errors.email?.message
  }
/>
Enter fullscreen mode Exit fullscreen mode

The username input reads:

errors.username
Enter fullscreen mode Exit fullscreen mode

The password input reads:

errors.password
Enter fullscreen mode Exit fullscreen mode

Therefore:

errors.email
     ↓
Email FormInput
     ↓
email-error paragraph
Enter fullscreen mode Exit fullscreen mode

It does not automatically appear under every input.

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


Cross-field error flow

The schema contains:

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

The path produces:

errors.confirmPassword
Enter fullscreen mode Exit fullscreen mode

Then:

<FormInput
  error={
    errors.confirmPassword
      ?.message
  }
/>
Enter fullscreen mode Exit fullscreen mode

displays it only under the confirmation field.

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

Frontend validation is not security

The React form can be bypassed.

An attacker can directly send:

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

{
  "username": "",
  "email": "invalid",
  "password": "1"
}
Enter fullscreen mode Exit fullscreen mode

Therefore, the backend must also validate the body.

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

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

The frontend provides fast feedback.

The backend enforces correctness.


Accessibility review

Our reusable input connects:

<label htmlFor={id}>
Enter fullscreen mode Exit fullscreen mode

to:

<input id={id} />
Enter fullscreen mode Exit fullscreen mode

When an error exists:

aria-invalid={true}
Enter fullscreen mode Exit fullscreen mode

and:

aria-describedby={
  `${id}-error`
}
Enter fullscreen mode Exit fullscreen mode

connect the field to:

<p id={`${id}-error`}>
Enter fullscreen mode Exit fullscreen mode

The relationship is:

Input
aria-describedby="email-error"
              ↓
Error paragraph
id="email-error"
Enter fullscreen mode Exit fullscreen mode

The form also uses:

role="alert"
Enter fullscreen mode Exit fullscreen mode

for a root server error.


Performance notes

Using Zod does not mean validation is free.

Validation still performs work.

Be deliberate about:

mode: "onChange"
Enter fullscreen mode Exit fullscreen mode

for large schemas because it can validate frequently.

A common balanced configuration is:

mode: "onBlur"
Enter fullscreen mode Exit fullscreen mode

or the default submit-first approach.

For expensive asynchronous checks such as username availability:

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

Common React Hook Form + Zod mistakes

Mistake 1: Defining the type separately

Avoid duplicating:

interface RegisterInput {
  username: string;
  email: string;
}
Enter fullscreen mode Exit fullscreen mode

and:

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

Prefer inference:

type RegisterInput =
  z.input<
    typeof registerSchema
  >;
Enter fullscreen mode Exit fullscreen mode

Mistake 2: Forgetting the resolver

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

This:

const schema =
  z.object({
    email: z.string(),
  });
Enter fullscreen mode Exit fullscreen mode

does nothing by itself.

Connect it:

useForm({
  resolver:
    zodResolver(schema),
});
Enter fullscreen mode Exit fullscreen mode

Mistake 3: Adding duplicate rules

Avoid unnecessarily validating the same field in both:

register("email", {
  required:
    "Email is required.",
})
Enter fullscreen mode Exit fullscreen mode

and:

z.string().min(
  1,
  "Email is required."
)
Enter fullscreen mode Exit fullscreen mode

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


Mistake 4: Forgetting the cross-field path

Bad:

.refine(
  passwordsMatch,
  {
    message:
      "Passwords do not match.",
  }
)
Enter fullscreen mode Exit fullscreen mode

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

Better:

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

Mistake 5: Trimming passwords automatically

This may be dangerous:

password:
  z
    .string()
    .trim()
Enter fullscreen mode Exit fullscreen mode

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

Normalize usernames and emails deliberately.

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


Mistake 6: Expecting Zod to check the database

Zod can validate:

Email has a valid format
Enter fullscreen mode Exit fullscreen mode

It cannot independently know:

Email already exists
Enter fullscreen mode Exit fullscreen mode

That requires a database query.

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

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

Senior engineer tips

Keep schemas outside components

Better:

register.schema.ts
RegisterForm.tsx
Enter fullscreen mode Exit fullscreen mode

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


Separate input and output types when transformations exist

type Input =
  z.input<typeof schema>;

type Output =
  z.output<typeof schema>;
Enter fullscreen mode Exit fullscreen mode

This becomes important when converting:

"21"
 ↓
21
Enter fullscreen mode Exit fullscreen mode

or:

"2026-08-24"
      ↓
Date object
Enter fullscreen mode Exit fullscreen mode

Use schemas at trust boundaries

Good validation boundaries include:

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

Keep the API layer separate

Avoid placing every Fetch detail inside the form component.

Better:

RegisterForm
    ↓
registerUser()
    ↓
HTTP request
Enter fullscreen mode Exit fullscreen mode

The form handles interface behavior.

The API function handles communication.


Interview questions

What is a resolver in React Hook Form?

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

Why combine React Hook Form and Zod?

React Hook Form manages interactive form state and field registration.

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

How does a Zod error appear in errors.email?

Zod produces an issue whose path contains email.

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

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

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

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

Should the backend validate again when the frontend uses Zod?

Yes.

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


Part 3 summary

React Hook Form changed the form architecture from:

Every keystroke
    ↓
Parent React state
    ↓
Complete form component render
Enter fullscreen mode Exit fullscreen mode

toward:

Native input stores value
    ↓
register connects field
    ↓
Form control tracks state
    ↓
Subscribed UI updates
Enter fullscreen mode Exit fullscreen mode

Its major APIs can be organized as:

Create
└── useForm

Connect
├── register
└── Controller

Read
├── watch
└── getValues

Write
└── setValue

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

Reset
├── reset
└── resetField

Observe
└── formState
Enter fullscreen mode Exit fullscreen mode

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

Manual conditions
        ↓
Schema validation
        ↓
Runtime safety
        ↓
Type inference
        ↓
Shared validation contracts
Enter fullscreen mode Exit fullscreen mode

Finally, React Hook Form and Zod combined their responsibilities:

React Hook Form
→ form behavior

Zod
→ validation contract

zodResolver
→ connection between them
Enter fullscreen mode Exit fullscreen mode

The next part will cover:

Stage 13
Server-side validation with Express

Stage 14
Mapping backend errors with setError()

Stage 15
Accessible production forms

Stage 16
Form performance and render comparisons
Enter fullscreen mode Exit fullscreen mode

Top comments (0)