Form Validation with Zod

Schema-first form validation with React Hook Form and Zod. Type-safe, declarative error messages, and zero boilerplate for create and edit flows.

01

Principle

The Zod schema is the single source of truth — it defines the shape, types, and error messages. React Hook Form handles registration, submission, and field state. Components never write validation logic; they display what the schema declares.

lightbulb

Write the schema before a single input. Share schemas across forms with .pick(), .extend(), or .omit(). Keep all error messages inside the schema, not in JSX.

02

Rules

  • check_circle
    Schema before formDefine the Zod schema first. Never add validation inline with register options or manual if-statements.
  • check_circle
    Omit server-generated fieldsUse .omit({ id: true, createdAt: true }) for create forms. The schema reflects what the user provides.
  • check_circle
    handleSubmit owns errorsWrap mutation calls in handleSubmit. Validation errors surface automatically without try/catch in the component.
  • check_circle
    Reset after successCall reset() after a successful mutation to clear all field values and dirty state.
  • check_circle
    Share schemas between create and editDefine a base schema, then derive create and edit variants with .omit() or .partial(). Single source of truth for all validation rules.
03

Pattern

shared/utils/validators.ts
import { z } from 'zod';

// Base schema matching the User interface
const userSchema = z.object({
  id:        z.string().min(1, 'ID is required'),
  name:      z.string().min(1, 'Name is required'),
  email:     z.string().email('Enter a valid email address'),
  role:      z.enum(['viewer', 'editor', 'admin']),
  status:    z.enum(['active', 'inactive']),
  createdAt: z.string().datetime({ message: 'Invalid ISO date string' }),
});

// Create: omit server-generated fields
export const createUserSchema = userSchema.omit({ id: true, createdAt: true });

// Edit: partial of create schema — all fields optional
export const editUserSchema = createUserSchema.partial();

export type CreateUserValues = z.infer<typeof createUserSchema>;
export type EditUserValues   = z.infer<typeof editUserSchema>;
04

Implementation

info

Version Compatibility

Requires React 19+ and the latest stable versions of all dependencies shown.

In Next.js App Router, pair the form with a React Query mutation. The form is a Client Component ('use client'). On success, invalidate the users list so the table refreshes automatically. Reset the form to clear dirty state.

features/examples/components/UserForm.tsx
'use client';

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { type z } from 'zod';
import { userSchema } from '@/shared/utils/validators';
import { useCreateUser } from '@/features/examples/hooks/useCreateUser';

const createUserFormSchema = userSchema.omit({ id: true, createdAt: true });
type CreateUserFormValues = z.infer<typeof createUserFormSchema>;

export function UserForm() {
  const { register, handleSubmit, reset,
    formState: { errors, isSubmitting } } = useForm<CreateUserFormValues>({
    resolver: zodResolver(createUserFormSchema),
    defaultValues: { name: '', email: '', role: 'viewer', status: 'active' },
  });

  const createMutation = useCreateUser();

  const onSubmit = async (data: CreateUserFormValues) => {
    await createMutation.mutateAsync(data);
    reset();
  };

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

      <input {...register('email')} placeholder="Email" />
      {errors.email && <p>{errors.email.message}</p>}

      <select {...register('role')}>
        <option value="viewer">Viewer</option>
        <option value="editor">Editor</option>
        <option value="admin">Admin</option>
      </select>

      <select {...register('status')}>
        <option value="active">Active</option>
        <option value="inactive">Inactive</option>
      </select>

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

Live Demo

menu_book
React Patterns

Helping developers build robust React applications since 2026.

© 2026 React Patterns Cookbook. Built with ❤️ for the community.
React Principles