# 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.

**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.

**Tip:** 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.

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

**Pattern** — `shared/utils/validators.ts`

```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>;
```

**Implementation — Next.js**

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.

File: `features/examples/components/UserForm.tsx`

```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>
  );
}
```

**Implementation — Vite**

In Vite, the pattern is identical — React Query mutation with cache invalidation. The only difference is routing: use react-router instead of Next.js file-based routing.

File: `features/examples/components/UserEditForm.tsx`

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

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

export function UserEditForm({ id }: { id: string }) {
  const { data: user } = useUser(id);
  const updateMutation = useUpdateUser(id);

  const { register, handleSubmit, reset,
    formState: { errors, isSubmitting } } = useForm<EditUserFormValues>({
    resolver: zodResolver(editUserFormSchema),
  });

  // Pre-populate form when user data loads
  useEffect(() => {
    if (user) {
      reset({
        name:   user.name,
        email:  user.email,
        role:   user.role,
        status: user.status,
      });
    }
  }, [user, reset]);

  const onSubmit = async (data: EditUserFormValues) => {
    await updateMutation.mutateAsync(data);
  };

  if (!user) return <p>Loading...</p>;

  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}>Save Changes</button>
    </form>
  );
}
```

Read more: https://reactprinciples.dev/cookbook/form-validation

---

Source: https://reactprinciples.dev/nextjs/cookbook/form-validation
All recipes (compact): https://reactprinciples.dev/llms.txt
All recipes (full): https://reactprinciples.dev/llms-full.txt
