Halloween DealsAmazon USHalloween week deals: useful picks before month-endAmazon US: current deals, useful picks and tech finds.Check DealsClean PCRecommendedOne scan can reveal what keeps slowing WindowsLook for cleanup and repair opportunities.Run ScanHalloween DealsAmazon USOctober picks are moving - compare before checkoutAmazon US: current deals, useful picks and tech finds.See Picks×
FREEDOM251

Freedom251 › Blog › Form Validation

React Form Validation with React Hook Form and Zod: A Complete Type-Safe Guide

By Freedom251 Team · · 5 min read

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

Use React Hook Form to manage fields, submission, and error state, and let Zod define the runtime rules. Connect them with zodResolver(schema); the resolver parses submitted values and maps Zod issues back to the matching registered fields. This gives you immediate client feedback while keeping one schema as the source of truth for TypeScript and validation.

Install the integration

Install React Hook Form, Zod, and the resolver adapter in the project that renders your form:

As an Amazon Associate I earn from qualifying purchases.

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

The resolver package contains the Zod adapter used by zodResolver.

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

Build a working form

The following component validates an email and an age, displays field messages, and disables submission while the form is being processed:

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) => {
    console.log(data);
  };

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

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

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

What each part does

  • register('email') connects the input to React Hook Form and establishes the field path.
  • resolver: zodResolver(formSchema) runs the schema whenever React Hook Form validates.
  • handleSubmit calls onSubmit only after a successful parse. Invalid data populates formState.errors instead.
  • valueAsNumber: true converts the number control before Zod receives it.
  • noValidate prevents browser-native messages from competing with your Zod messages.

Keep field names and schema keys aligned

Resolver errors are assigned by path. A schema key named email must be registered as register('email'); registering userEmail creates a different path, so the message will not appear beside the intended control. The same rule applies to nested objects and arrays: preserve the exact path when registering fields.

Use matching labels, id attributes, and role="alert" text so screen-reader users can identify the invalid control and its message.

Understand the validation data flow

  1. The user edits controls that React Hook Form has registered.
  2. On submit, blur, change, or another configured event, React Hook Form passes the current values to the resolver.
  3. Zod parses those values against the object schema.
  4. A successful parse returns validated values; a failed parse becomes field-level errors.
  5. The success callback receives parsed data, while the component renders messages from formState.errors.

Fix the number-field string problem

HTML controls normally emit strings, even when type="number" is used. A Zod rule such as z.number() therefore fails if the resolver receives "17". Convert at the registration point:

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
<input type="number" {...register('age', { valueAsNumber: true })} />

Alternatively, coerce or preprocess in the schema when that better represents your input boundary:

const schema = z.object({
  age: z.coerce.number().int().min(13),
});

Choose one deliberate conversion strategy. For optional numbers, decide what an empty control means: preprocess an empty string to undefined, or model the field as nullable. Do not allow NaN to pass silently; normalize it or return a clear validation message.

Model transformed values correctly in TypeScript

For an ordinary schema, z.infer<typeof schema> describes the form values. 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 submit handler receives a Date. The third generic tells React Hook Form about the resolver’s transformed output instead of treating both sides as the same type.

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

Choose validation timing and resolver behavior

React Hook Form can validate on submit, blur, change, or other configured events. Select the event that fits the form: submit-only validation reduces interruptions, while blur or change can shorten correction cycles for small forms.

The resolver API supports mode: 'async' | 'sync'; asynchronous mode is the default. Use synchronous mode only when every check is synchronous. If a rule needs an API call, such as checking username availability, keep that asynchronous work out of a purely synchronous path and show a pending state. The resolver also exposes a raw option when you need access to the unparsed resolver result, but normal forms should consume the validated output.

Handle cross-field and conditional rules

Use an object-level refinement when one value depends on another, such as matching passwords. Attach a path so the resulting message appears beside the field the user must change:

const schema = z.object({
  password: z.string().min(8),
  confirmPassword: z.string(),
}).refine((values) => values.password === values.confirmPassword, {
  path: ['confirmPassword'],
  message: 'Passwords must match',
});

For mutually exclusive shapes, use a discriminated union keyed by a field such as kind. For multi-step forms, split schemas by step or domain and compose them so each screen validates the data it owns.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

Troubleshoot common failures

“Expected number, received string”

Add valueAsNumber: true to the registration or use an explicit Zod coercion/preprocessing rule. Also handle empty values and NaN deliberately.

Errors are empty

Verify that zodResolver(schema) is passed to the same useForm instance that renders the fields. Then compare every registered name with the schema’s keys and nested paths.

The submit callback never runs

handleSubmit intentionally skips the success callback when any field is invalid. Render or inspect formState.errors to find the blocking path.

Types are wrong after a transform

Use z.input and z.output, then pass useForm<Input, Context, Output> so input and parsed output remain distinct.

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

The server accepts invalid data

Client validation improves feedback but is not a trust boundary. Parse the request again with the same schema, or an equivalent server-side schema, before writing data or performing sensitive actions.

What this division of responsibility buys you

  • React Hook Form owns registration, form state, submission flow, validation timing, and error storage.
  • Zod owns declarative runtime constraints, refinements, coercion, transformations, and TypeScript inference.
  • The resolver is the boundary that converts Zod’s parse result into React Hook Form’s field errors and validated submission data.

Keeping those responsibilities separate makes the form easier to test and lets you reuse the schema at the server boundary without making browser state management part of your validation rules.

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.

Form ValidationReactReact Hook FormTypeScriptZod

Leave a Reply

Your email address will not be published. Required fields are marked *

Free tools Windows power users keep installed

One-click scans. No signup required.

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

Recommended PC Tool
Recommended PC Tool
PC Slower Than It Used to Be?Free scan - under a minute
Outdated Drivers Are Slowing You DownFree scan - exact matches

Two free Windows tools

One Free Minute Could Fix That PC

Before you go - each of these free tools takes about a minute and tackles what quietly slows a Windows PC down.

Special offer. View Outbyte info, uninstall instructions, EULA, and Privacy Policy.