React Development · Lesson 4 of 15

Forms & Validation

Why React Hook Form + Zod

The problem with native form handling:

TSX
// ❌ The naive approach — 50+ lines for a simple form
function NaiveForm() {
  const [email, setEmail] = useState("");
  const [emailError, setEmailError] = useState("");
  const [password, setPassword] = useState("");
  const [passwordError, setPasswordError] = useState("");
  const [loading, setLoading] = useState(false);

  const validate = () => {
    let valid = true;
    if (!email.includes("@")) { setEmailError("Invalid email"); valid = false; }
    if (password.length < 8) { setPasswordError("Too short"); valid = false; }
    return valid;
  };

  // This approach: re-renders on every keystroke, validation logic scattered,
  // no schema sharing, no type safety, no accessibility
}

React Hook Form + Zod:

  • Uncontrolled inputs — minimal re-renders (only on error state change)
  • Zod schemas are the single source of truth for validation AND TypeScript types
  • Built-in accessibility (aria attributes on errors)
  • Works with any UI library
Bash
pnpm add react-hook-form zod @hookform/resolvers

Foundation: Schema-First Form Design

Always define the schema first. The TypeScript types come from it automatically.

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

// 1. Define the schema
const registrationSchema = z
  .object({
    name: z
      .string()
      .min(2, "Name must be at least 2 characters")
      .max(50, "Name is too long"),
    email: z
      .string()
      .email("Enter a valid email address")
      .toLowerCase(),  // Transform: normalize email
    password: z
      .string()
      .min(8, "Password must be at least 8 characters")
      .regex(/[A-Z]/, "Must contain an uppercase letter")
      .regex(/[0-9]/, "Must contain a number")
      .regex(/[^A-Za-z0-9]/, "Must contain a special character"),
    confirmPassword: z.string(),
    role: z.enum(["developer", "designer", "manager"], {
      required_error: "Please select a role",
    }),
    agreeToTerms: z
      .boolean()
      .refine((v) => v === true, "You must agree to the terms"),
  })
  .refine((data) => data.password === data.confirmPassword, {
    message: "Passwords don't match",
    path: ["confirmPassword"],  // Which field to attach the error to
  });

// 2. Derive the type from the schema
type RegistrationFormData = z.infer<typeof registrationSchema>;
// This is the same as:
// type RegistrationFormData = {
//   name: string;
//   email: string;
//   password: string;
//   confirmPassword: string;
//   role: "developer" | "designer" | "manager";
//   agreeToTerms: boolean;
// }

// 3. Build the form
function RegistrationForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting, isDirty, isValid },
    watch,
    reset,
    setError,
  } = useForm<RegistrationFormData>({
    resolver: zodResolver(registrationSchema),
    mode: "onBlur",   // Validate on blur (not on every keystroke)
    defaultValues: {
      name: "",
      email: "",
      password: "",
      confirmPassword: "",
      agreeToTerms: false,
    },
  });

  const onSubmit = async (data: RegistrationFormData) => {
    try {
      await registerUser(data);
      reset();  // Clear form on success
    } catch (err: any) {
      if (err.code === "EMAIL_TAKEN") {
        // Set server-side errors on specific fields
        setError("email", { message: "This email is already registered" });
      } else {
        setError("root", { message: err.message ?? "Registration failed" });
      }
    }
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)} noValidate>
      <div className="space-y-4">
        <Field label="Full Name" error={errors.name?.message}>
          <input
            {...register("name")}
            type="text"
            placeholder="Alice Johnson"
          />
        </Field>

        <Field label="Email" error={errors.email?.message}>
          <input
            {...register("email")}
            type="email"
            placeholder="alice@example.com"
          />
        </Field>

        <Field label="Password" error={errors.password?.message}>
          <input {...register("password")} type="password" />
          <PasswordStrengthIndicator password={watch("password")} />
        </Field>

        <Field label="Confirm Password" error={errors.confirmPassword?.message}>
          <input {...register("confirmPassword")} type="password" />
        </Field>

        <Field label="Role" error={errors.role?.message}>
          <select {...register("role")}>
            <option value="">Select your role...</option>
            <option value="developer">Developer</option>
            <option value="designer">Designer</option>
            <option value="manager">Manager</option>
          </select>
        </Field>

        <label className="flex items-start gap-3">
          <input {...register("agreeToTerms")} type="checkbox" className="mt-1" />
          <span>
            I agree to the{" "}
            <a href="/terms" className="text-blue-600 underline">Terms of Service</a>
          </span>
        </label>
        {errors.agreeToTerms && (
          <p className="text-red-600 text-sm">{errors.agreeToTerms.message}</p>
        )}

        {errors.root && (
          <div className="bg-red-50 border border-red-200 text-red-700 p-3 rounded">
            {errors.root.message}
          </div>
        )}

        <button
          type="submit"
          disabled={isSubmitting || !isDirty}
          className="w-full btn btn-primary"
        >
          {isSubmitting ? "Creating account..." : "Create Account"}
        </button>
      </div>
    </form>
  );
}

Reusable Field Component

TSX
// Wrap every input with this — handles aria, labels, and errors
interface FieldProps {
  label: string;
  error?: string;
  hint?: string;
  required?: boolean;
  children: React.ReactElement;
}

function Field({ label, error, hint, required, children }: FieldProps) {
  const id = useId();
  const errorId = `${id}-error`;
  const hintId = `${id}-hint`;

  // Clone child to inject id and aria attributes
  const input = React.cloneElement(children, {
    id,
    "aria-describedby": [error && errorId, hint && hintId]
      .filter(Boolean)
      .join(" ") || undefined,
    "aria-invalid": error ? true : undefined,
  });

  return (
    <div>
      <label htmlFor={id} className="block text-sm font-medium text-gray-700 mb-1">
        {label}
        {required && <span className="text-red-500 ml-1" aria-hidden>*</span>}
      </label>
      {input}
      {hint && !error && (
        <p id={hintId} className="text-sm text-gray-500 mt-1">{hint}</p>
      )}
      {error && (
        <p id={errorId} className="text-sm text-red-600 mt-1" role="alert">
          {error}
        </p>
      )}
    </div>
  );
}

Multi-Step Form

TSX
const step1Schema = z.object({
  firstName: z.string().min(1, "Required"),
  lastName: z.string().min(1, "Required"),
  email: z.string().email("Invalid email"),
});

const step2Schema = z.object({
  company: z.string().min(1, "Required"),
  role: z.string().min(1, "Required"),
  size: z.enum(["1-10", "11-50", "51-200", "200+"]),
});

const step3Schema = z.object({
  plan: z.enum(["free", "pro", "enterprise"]),
  billingCycle: z.enum(["monthly", "annual"]).optional(),
  coupon: z.string().optional(),
});

const fullSchema = step1Schema.merge(step2Schema).merge(step3Schema);
type OnboardingData = z.infer<typeof fullSchema>;

function MultiStepOnboarding() {
  const [step, setStep] = useState(1);

  const form = useForm<OnboardingData>({
    resolver: zodResolver(fullSchema),
    mode: "onBlur",
    defaultValues: {
      firstName: "", lastName: "", email: "",
      company: "", role: "", size: "1-10",
      plan: "free",
    },
  });

  const stepSchemas = [step1Schema, step2Schema, step3Schema];

  const nextStep = async () => {
    // Validate only the current step's fields
    const currentSchema = stepSchemas[step - 1];
    const fields = Object.keys(currentSchema.shape) as (keyof OnboardingData)[];
    const valid = await form.trigger(fields);
    if (valid) setStep((s) => s + 1);
  };

  const onSubmit = async (data: OnboardingData) => {
    await createAccount(data);
  };

  return (
    <div className="max-w-lg mx-auto">
      {/* Progress indicator */}
      <StepProgress current={step} total={3} />

      <form onSubmit={form.handleSubmit(onSubmit)}>
        {step === 1 && <Step1 form={form} />}
        {step === 2 && <Step2 form={form} />}
        {step === 3 && <Step3 form={form} />}

        <div className="flex gap-3 mt-6">
          {step > 1 && (
            <button type="button" onClick={() => setStep((s) => s - 1)}>
              Back
            </button>
          )}
          {step < 3 ? (
            <button type="button" onClick={nextStep}>
              Next
            </button>
          ) : (
            <button type="submit" disabled={form.formState.isSubmitting}>
              {form.formState.isSubmitting ? "Creating..." : "Create Account"}
            </button>
          )}
        </div>
      </form>
    </div>
  );
}

function Step1({ form }: { form: UseFormReturn<OnboardingData> }) {
  const { register, formState: { errors } } = form;
  return (
    <div className="space-y-4">
      <h2>Personal Information</h2>
      <Field label="First Name" error={errors.firstName?.message} required>
        <input {...register("firstName")} type="text" />
      </Field>
      <Field label="Last Name" error={errors.lastName?.message} required>
        <input {...register("lastName")} type="text" />
      </Field>
      <Field label="Email" error={errors.email?.message} required>
        <input {...register("email")} type="email" />
      </Field>
    </div>
  );
}

Dynamic/Nested Fields with useFieldArray

TSX
const invoiceSchema = z.object({
  clientName: z.string().min(1, "Required"),
  lineItems: z.array(
    z.object({
      description: z.string().min(1, "Required"),
      quantity: z.number().min(1, "Must be at least 1"),
      unitPrice: z.number().min(0, "Cannot be negative"),
    })
  ).min(1, "Add at least one item"),
});

type InvoiceFormData = z.infer<typeof invoiceSchema>;

function InvoiceForm() {
  const { register, control, handleSubmit, watch, formState: { errors } } =
    useForm<InvoiceFormData>({
      resolver: zodResolver(invoiceSchema),
      defaultValues: {
        clientName: "",
        lineItems: [{ description: "", quantity: 1, unitPrice: 0 }],
      },
    });

  const { fields, append, remove, move } = useFieldArray({
    control,
    name: "lineItems",
  });

  const lineItems = watch("lineItems");
  const total = lineItems.reduce(
    (sum, item) => sum + (item.quantity || 0) * (item.unitPrice || 0),
    0
  );

  return (
    <form onSubmit={handleSubmit(console.log)}>
      <Field label="Client Name" error={errors.clientName?.message} required>
        <input {...register("clientName")} />
      </Field>

      <div className="mt-6">
        <h3 className="font-medium mb-3">Line Items</h3>

        {fields.map((field, index) => (
          <div key={field.id} className="flex gap-3 mb-3 items-start">
            <Field
              label={index === 0 ? "Description" : ""}
              error={errors.lineItems?.[index]?.description?.message}
            >
              <input
                {...register(`lineItems.${index}.description`)}
                placeholder="Service description"
              />
            </Field>

            <Field
              label={index === 0 ? "Qty" : ""}
              error={errors.lineItems?.[index]?.quantity?.message}
            >
              <input
                {...register(`lineItems.${index}.quantity`, { valueAsNumber: true })}
                type="number"
                min="1"
                className="w-20"
              />
            </Field>

            <Field
              label={index === 0 ? "Price" : ""}
              error={errors.lineItems?.[index]?.unitPrice?.message}
            >
              <input
                {...register(`lineItems.${index}.unitPrice`, { valueAsNumber: true })}
                type="number"
                min="0"
                step="0.01"
                className="w-28"
              />
            </Field>

            <div className={index === 0 ? "mt-6" : ""}>
              <span className="text-sm text-gray-500 w-20 inline-block text-right">
                ${((lineItems[index]?.quantity || 0) * (lineItems[index]?.unitPrice || 0)).toFixed(2)}
              </span>
              <button
                type="button"
                onClick={() => remove(index)}
                disabled={fields.length === 1}
                className="ml-2 text-red-500 hover:text-red-700 disabled:opacity-30"
              >

              </button>
            </div>
          </div>
        ))}

        {errors.lineItems?.root && (
          <p className="text-red-600 text-sm">{errors.lineItems.root.message}</p>
        )}

        <button
          type="button"
          onClick={() => append({ description: "", quantity: 1, unitPrice: 0 })}
          className="text-sm text-blue-600 hover:underline"
        >
          + Add Item
        </button>
      </div>

      <div className="mt-4 text-right font-medium">
        Total: ${total.toFixed(2)}
      </div>

      <button type="submit" className="mt-6 w-full btn btn-primary">
        Send Invoice
      </button>
    </form>
  );
}

File Upload with Validation

TSX
const uploadSchema = z.object({
  title: z.string().min(1, "Required"),
  files: z
    .instanceof(FileList)
    .refine((files) => files.length > 0, "Select at least one file")
    .refine(
      (files) => Array.from(files).every((f) => f.size < 5 * 1024 * 1024),
      "Each file must be under 5MB"
    )
    .refine(
      (files) => Array.from(files).every((f) => ["image/jpeg", "image/png", "image/webp"].includes(f.type)),
      "Only JPEG, PNG, and WebP images are allowed"
    ),
});

type UploadFormData = z.infer<typeof uploadSchema>;

function ImageUploadForm() {
  const { register, handleSubmit, watch, formState: { errors } } =
    useForm<UploadFormData>({ resolver: zodResolver(uploadSchema) });

  const files = watch("files");
  const previews = files
    ? Array.from(files).map((f) => URL.createObjectURL(f))
    : [];

  const onSubmit = async (data: UploadFormData) => {
    const formData = new FormData();
    formData.append("title", data.title);
    Array.from(data.files).forEach((file) => formData.append("files", file));

    await fetch("/api/upload", { method: "POST", body: formData });
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <Field label="Title" error={errors.title?.message}>
        <input {...register("title")} />
      </Field>

      <div className="mt-4">
        <label className="block text-sm font-medium text-gray-700 mb-1">
          Images
        </label>
        <div className="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center hover:border-blue-400 transition-colors">
          <input
            {...register("files")}
            type="file"
            multiple
            accept="image/jpeg,image/png,image/webp"
            className="sr-only"
            id="file-upload"
          />
          <label htmlFor="file-upload" className="cursor-pointer">
            <p className="text-gray-600">Drop images here or <span className="text-blue-600">browse</span></p>
            <p className="text-xs text-gray-400 mt-1">JPEG, PNG, WebP — max 5MB each</p>
          </label>
        </div>
        {errors.files && (
          <p className="text-sm text-red-600 mt-1">{errors.files.message}</p>
        )}
      </div>

      {previews.length > 0 && (
        <div className="flex gap-2 mt-3 flex-wrap">
          {previews.map((url, i) => (
            <img
              key={i}
              src={url}
              alt={`Preview ${i + 1}`}
              className="h-20 w-20 object-cover rounded"
            />
          ))}
        </div>
      )}

      <button type="submit" className="mt-4 btn btn-primary">
        Upload
      </button>
    </form>
  );
}

Async Validation (Email Availability Check)

TSX
const signupSchema = z.object({
  email: z
    .string()
    .email("Invalid email")
    .refine(
      async (email) => {
        // Only check availability if email is valid format
        const res = await fetch(`/api/check-email?email=${encodeURIComponent(email)}`);
        const { available } = await res.json();
        return available;
      },
      "This email is already registered"
    ),
  password: z.string().min(8),
});

function SignupForm() {
  const { register, handleSubmit, formState: { errors, isValidating } } =
    useForm({
      resolver: zodResolver(signupSchema),
      mode: "onBlur",  // Important: async validation runs on blur
    });

  return (
    <form onSubmit={handleSubmit(console.log)}>
      <Field label="Email" error={errors.email?.message}>
        <div className="relative">
          <input {...register("email")} type="email" />
          {isValidating && (
            <span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-gray-500">
              Checking...
            </span>
          )}
        </div>
      </Field>
      <Field label="Password" error={errors.password?.message}>
        <input {...register("password")} type="password" />
      </Field>
      <button type="submit">Sign Up</button>
    </form>
  );
}

Controlled vs Uncontrolled — When to Use Controller

React Hook Form uses uncontrolled inputs by default (refs, not state). For custom components that don't accept ref, use Controller:

TSX
import { Controller } from "react-hook-form";

// Using a UI library component (e.g., a date picker, rich text editor, custom select)
function ProfileForm() {
  const { control, handleSubmit } = useForm();

  return (
    <form onSubmit={handleSubmit(console.log)}>
      {/* React Hook Form controls the value and onChange */}
      <Controller
        name="birthDate"
        control={control}
        rules={{ required: "Birth date is required" }}
        render={({ field, fieldState }) => (
          <DatePicker
            selected={field.value}
            onChange={field.onChange}
            onBlur={field.onBlur}
            placeholderText="Select date"
            error={fieldState.error?.message}
          />
        )}
      />

      <Controller
        name="skills"
        control={control}
        render={({ field }) => (
          <MultiSelect
            value={field.value}
            onChange={field.onChange}
            options={skillOptions}
          />
        )}
      />
    </form>
  );
}

Interview Questions Answered

Q: What is the difference between controlled and uncontrolled components?

A controlled component has its value managed by React state — useState + onChange updates state which re-renders the input. An uncontrolled component stores its value in the DOM — accessed via ref.current.value. React Hook Form uses uncontrolled inputs for performance (no re-renders on keystroke) but gives you the values on submit.

Q: Why use Zod over Yup for validation?

Both are schema validation libraries. Zod has better TypeScript integration — types are inferred directly from schemas (z.infer<typeof schema>). Zod has a functional API that's more composable, and it's tree-shakeable. Yup is more mature and has wider community support. For new TypeScript projects, Zod is generally preferred.

Q: How does React Hook Form minimize re-renders?

RHF registers inputs as uncontrolled (via ref) and only reads values on submit or explicit calls to getValues(). The form state (errors, dirty, isSubmitting) is stored outside React state in a class, and only triggers re-renders when you actually access those values through formState. Contrast with controlled forms where every keystroke triggers setState → re-render.

Q: When would you use watch() and what are the performance implications?

watch() subscribes to field value changes and re-renders the component when the watched field updates. Use it when you need to: show dependent fields based on another field's value, display a live preview, or calculate a derived value (like the total in an invoice). For performance, watch specific fields (watch("email")) rather than the whole form (watch()).