Skip to content
Innovialabs

Forms in React with React Hook Form and Zod Validation

Practice4 min

In short

React Hook Form manages fields and submission without extra re-renders, while a Zod schema defines validation rules once, and the same schema can validate data on the server.

Why this pairing

React Hook Form keeps field values outside React state and does not re-render the whole form on every keystroke. Zod describes data as a schema: types, constraints and error messages in one place. The @hookform/resolvers package connects the two.

The main benefit is one schema on the client and the server. Rules never drift apart, and the form's TypeScript type is inferred from the schema automatically.

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

Step 1. A schema with clear messages

We will build a lead form: name, phone in the Uzbek format and a comment.

// lib/lead-schema.ts
import { z } from 'zod';

const UZ_PHONE = /^\+998\d{9}$/;

export const leadSchema = z.object({
  name: z.string().trim().min(2, 'Enter your name, at least 2 characters'),
  phone: z
    .string()
    .refine(
      (v) => UZ_PHONE.test(v.replace(/[\s()-]/g, '')),
      'Use the format +998 90 123 45 67',
    ),
  message: z.string().max(1000, 'No more than 1000 characters').optional(),
});

export type Lead = z.infer<typeof leadSchema>;

What matters here:

  • messages in the user's language are passed as the second argument;
  • the phone is checked after removing spaces, brackets and hyphens, so +998 (90) 123-45-67 passes too;
  • an Uzbek number is +998 followed by 9 digits (operator code and subscriber number).

Step 2. Registering fields

'use client';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { leadSchema, type Lead } from '@/lib/lead-schema';

export function LeadForm() {
  const {
    register,
    handleSubmit,
    setError,
    reset,
    formState: { errors, isSubmitting },
  } = useForm<Lead>({ resolver: zodResolver(leadSchema) });

  return (
    <form onSubmit={handleSubmit(onSubmit)} noValidate>
      <input {...register('name')} placeholder="Name" />
      {errors.name && <p role="alert">{errors.name.message}</p>}

      <input {...register('phone')} type="tel" placeholder="+998 90 123 45 67" />
      {errors.phone && <p role="alert">{errors.phone.message}</p>}

      <textarea {...register('message')} />
      {errors.message && <p role="alert">{errors.message.message}</p>}

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

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Sending…' : 'Send'}
      </button>
    </form>
  );
}

register connects a field to the form, errors holds the texts from the schema, and isSubmitting disables the button against double submits. The noValidate attribute turns off native browser hints so users do not see two kinds of errors.

Step 3. Async submit and server errors

Declare onSubmit inside the component, since it needs setError and reset:

async function onSubmit(data: Lead) {
  const res = await fetch('/api/lead', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(data),
  });

  if (!res.ok) {
    const body = await res.json().catch(() => null);
    const fieldErrors: Record<string, string> = body?.fieldErrors ?? {};
    const entries = Object.entries(fieldErrors);
    if (entries.length) {
      for (const [field, message] of entries) {
        setError(field as keyof Lead, { message });
      }
    } else {
      setError('root.server', { message: 'Could not send. Please try again.' });
    }
    return;
  }

  reset();
}

handleSubmit only calls onSubmit when client validation passes. Server errors are mapped to fields with setError, and a general error goes to root.server.

Step 4. The same schema on the server

Never trust client-side checks alone: a request can bypass the form. Here is a Next.js Route Handler:

// app/api/lead/route.ts
import { leadSchema } from '@/lib/lead-schema';

export async function POST(req: Request) {
  const result = leadSchema.safeParse(await req.json().catch(() => null));

  if (!result.success) {
    const fieldErrors: Record<string, string> = {};
    for (const issue of result.error.issues) {
      const key = String(issue.path[0] ?? 'form');
      fieldErrors[key] ??= issue.message;
    }
    return Response.json({ fieldErrors }, { status: 400 });
  }

  // result.data is validated and typed, save the lead
  return Response.json({ ok: true });
}

The schema lives in a shared file, so changing a rule takes effect in both the browser and the server at once.

Common mistakes

  • Validating only on the client.
  • Keeping every field in useState alongside React Hook Form.
  • Writing rules twice: in register({ required }) and in the schema.
  • Forgetting to disable the button while submitting.
  • Showing raw default Zod messages instead of your own texts.

FAQ

No, everything works in JavaScript too. With TypeScript, though, you get the form type from the schema for free, and typos in field names are caught at build time.

We can help with this

If you'd rather get the result than figure it all out yourself — this is what we do every day.

Discuss a project

Read next

Have an idea or a task? Tell us about it

We'll reply within one business day and propose a solution. The consultation is free.