Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

iTechGuides is reader-supported. When you buy through links on our site, we may earn an affiliate commission. As an Amazon Associate I earn from qualifying purchases. Learn more

Use React Hook Form to register fields, track state, submit data, and expose errors; use Zod to define runtime validation rules and infer TypeScript types. Connect them with zodResolver(schema) passed to useForm. This division keeps browser feedback immediate while giving your form a single, declarative schema that can also be applied at the server boundary.

Install the validation stack

Install React Hook Form, Zod, and the official resolver adapter in your project:

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

The resolver package supplies the Zod adapter that translates a schema result into React Hook Form field errors.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Build a minimal validated form

The following component validates an email and an age, displays messages beside the corresponding controls, and disables submission while the request is in progress.

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

const formSchema = z.object({
  email: z.string().email('Enter a valid email address'),
  age: z.number().int().min(13, 'You must be at least 13'),
});

type FormValues = z.infer<typeof formSchema>;

export function SignupForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<FormValues>({
    resolver: zodResolver(formSchema),
    defaultValues: { email: '', age: undefined },
  });

  const onSubmit = (data: FormValues) => {
    // Send validated data to your API.
    console.log(data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)} noValidate>
      <label>
        Email
        <input type="email" {...register('email')} />
      </label>
      {errors.email && <p role="alert">{errors.email.message}</p>}

      <label>
        Age
        <input type="number" {...register('age', { valueAsNumber: true })} />
      </label>
      {errors.age && <p role="alert">{errors.age.message}</p>}

      <button type="submit" disabled={isSubmitting}>Create account</button>
    </form>
  );
}

How each part connects

  1. register('email') connects the input to React Hook Form and establishes the field path used in state and errors.
  2. On submit, or on the validation event you configure, React Hook Form passes current values to the resolver.
  3. zodResolver parses those values with the Zod object. A successful parse produces validated data; a failure becomes field-level errors.
  4. handleSubmit calls onSubmit only after validation succeeds. If any field is invalid, inspect formState.errors instead.

Keep names, labels, and errors aligned

Every registered name must match a key in the schema. If the schema uses email but JSX registers contactEmail, the resolver cannot place the message beside the intended control. Keep the label, input id, registration name, and error display associated with the same field. Render messages in an element such as <p role="alert"> so assistive technology announces them.

Handle numbers and empty controls

Native HTML inputs usually emit strings, even when type="number". A Zod z.number() schema therefore receives the wrong type unless you convert it. register('age', { valueAsNumber: true }) performs the conversion before Zod runs.

For an optional number, decide what an empty control means. You can preprocess an empty string into undefined, or model the field as nullable, but do not quietly pass NaN. Add an explicit normalization rule or a clear validation message for empty and malformed values.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Keep transformed schemas type-safe

For an unchanged schema, z.infer<typeof schema> is normally sufficient. A transform creates different input and output types, so model both explicitly:

const schema = z.object({
  birthday: z.string().transform((value) => new Date(value)),
});

type Input = z.input<typeof schema>;
type Output = z.output<typeof schema>;

const form = useForm<Input, unknown, Output>({
  resolver: zodResolver(schema),
});

Here the browser supplies a string, while the successful submit value is a Date. The third useForm generic records that transformed output for the submit handler.

Choose when validation runs

React Hook Form supports validation on submit, blur, change, and other configured events. Submit-time validation minimizes interruptions; blur or change validation can provide earlier feedback. Select the timing that fits the form rather than validating every keystroke by default.

The resolver API supports mode: 'async' | 'sync'; asynchronous mode is the default. Use synchronous mode only when every check is synchronous. Username availability or other API-backed checks need an asynchronous path with a visible pending state, not a resolver configured as purely synchronous.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Validate dependent fields and multi-step forms

Cross-field rules

Use an object-level refinement when one value depends on another, such as matching password fields. Attach a meaningful path in the refinement so the resulting message appears beside the field the user must change. Discriminated unions are useful when different fields are required for different form modes.

Large and wizard-style forms

Split a large schema by step or domain, then compose the pieces. Each screen can validate the data it owns while the final submission still uses the complete schema.

Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

Troubleshoot common failures

Symptom Likely cause Fix
“Expected number, received string.” The browser supplied text. Use valueAsNumber: true or an explicit Zod coercion/preprocessing rule.
Errors are empty or appear on the wrong control. The resolver is attached to another useForm instance, or names differ. Pass resolver: zodResolver(schema) to the same instance that renders the fields and make registration names exactly match schema keys.
The success callback never runs. At least one field is invalid. Inspect formState.errors; handleSubmit intentionally blocks the success callback until the schema passes.
Transformed output has the wrong TypeScript type. Only the inferred output type was supplied. Declare useForm<Input, Context, Output> with z.input and z.output.
The server accepts invalid data. Only client validation was performed. Run the schema, or equivalent server-side validation, again at the trust boundary.

Client validation is not authorization

Browser code improves feedback and prevents many accidental submissions, but users can bypass it. Treat every API request as untrusted and validate again on the server before writing data, creating accounts, or making authorization decisions. Reusing the same Zod schema across client and server can reduce drift, while server-only rules should remain enforced on the server.

Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.