Skip to main content
Engineering Reference · 2026

React Forms Architecture

A production-grade handbook for form architecture in React applications. Covers React Hook Form, Zod schema validation, dynamic and multi-step forms, accessibility, async validation, server-side validation, Server Actions, optimistic submissions, error handling, and scalable form system design for 2026.

~55 min readIntermediate to AdvancedRHF · Zod · A11y · Server Actions · 2026
01 / Introduction

Forms as Architecture

Forms are one of the most deceptively complex surfaces in frontend engineering. A form is simultaneously a UI concern (layout, interaction, visual state), a validation concern (client rules, async checks, server errors), a state concern (field values, dirty state, submission lifecycle), and an accessibility concern (keyboard navigation, error announcements, focus management). Treating forms as simple HTML becomes a liability the moment any of those dimensions grows beyond trivial.

The right form architecture depends on scale. A contact form and a 40-field onboarding wizard with conditional branches, async field checks, and a multi-step layout are fundamentally different engineering problems. The patterns in this guide span the full range, from the simplest controlled form to enterprise-scale reusable form systems.

The three layers of every production form: validation (what is valid), state (what the user has entered and what stage the form is in), and submission (how the data moves to the server and what happens when it fails). Design each layer deliberately. Most form bugs come from these layers collapsing into each other.

Choosing the Right Approach

ScaleRecommended approachKey tools
Simple (1-5 fields, no async)Controlled form with React state, or React 19 Server Action with useActionStateReact, Zod for validation
Medium (5-20 fields, async validation, submit errors)React Hook Form + Zod resolverRHF, Zod, @hookform/resolvers
Complex (dynamic fields, multi-step, conditional logic)React Hook Form + Zod + useFieldArray + step state machineRHF, Zod, Zustand (for step state)
Enterprise (schema-driven, reusable across teams)Form system built on RHF with a typed field registryRHF, Zod, shared component library

What This Guide Covers

  • Controlled vs uncontrolled forms: the performance and DX trade-offs, and when uncontrolled patterns are the right choice.
  • React Hook Form: core API, FormProvider, Controller, and useFieldArray for production-grade form management.
  • Zod validation: schemas as the single source of truth for both runtime validation and TypeScript types.
  • Dynamic and multi-step forms: field arrays, conditional rendering, and step-based state machines.
  • Accessibility: ARIA patterns, error association, focus management, and live region announcements.
  • Server integration: Server Actions with React 19, server-error mapping, and optimistic submissions.
02 / Fundamentals

Controlled vs Uncontrolled Forms

A controlled form stores field values in React state and drives every input through a value prop. An uncontrolled form stores values in the DOM and reads them via refs or FormData when needed. The distinction matters because it directly determines how many renders your form triggers during user interaction.

Controlled Forms

typescriptcomponents/ContactForm.tsx (controlled, React state)
import { useState, type FormEvent } from "react";
import { z } from "zod";

const contactSchema = z.object({
  name: z.string().min(2, "Name must be at least 2 characters"),
  email: z.string().email("Enter a valid email address"),
  message: z.string().min(10, "Message must be at least 10 characters"),
});

type ContactFormData = z.infer<typeof contactSchema>;

export function ContactForm() {
  const [values, setValues] = useState<ContactFormData>({
    name: "",
    email: "",
    message: "",
  });
  const [errors, setErrors] = useState<Partial<Record<keyof ContactFormData, string>>>({});
  const [submitting, setSubmitting] = useState(false);

  function handleChange(field: keyof ContactFormData, value: string) {
    setValues((prev) => ({ ...prev, [field]: value }));
    // Clear field error on change
    if (errors[field]) setErrors((prev) => ({ ...prev, [field]: undefined }));
  }

  async function handleSubmit(e: FormEvent) {
    e.preventDefault();
    const result = contactSchema.safeParse(values);

    if (!result.success) {
      const fieldErrors: typeof errors = {};
      for (const issue of result.error.issues) {
        const field = issue.path[0] as keyof ContactFormData;
        fieldErrors[field] = issue.message;
      }
      setErrors(fieldErrors);
      return;
    }

    setSubmitting(true);
    try {
      await submitContact(result.data);
    } finally {
      setSubmitting(false);
    }
  }

  return (
    <form onSubmit={handleSubmit} noValidate>
      <div>
        <label htmlFor="name">Name</label>
        <input
          id="name"
          value={values.name}
          onChange={(e) => handleChange("name", e.target.value)}
          aria-describedby={errors.name ? "name-error" : undefined}
          aria-invalid={!!errors.name}
        />
        {errors.name && <p id="name-error" role="alert">{errors.name}</p>}
      </div>
      {/* email and message fields follow the same pattern */}
      <button type="submit" disabled={submitting}>
        {submitting ? "Sending..." : "Send message"}
      </button>
    </form>
  );
}

Uncontrolled Forms with React 19 Actions

React 19 introduced first-class form action support. You can pass a function directly to the action prop of a form. This is the uncontrolled pattern taken to its logical conclusion: no state, no onChange handlers, and the full power of progressive enhancement.

typescriptapp/contact/page.tsx (React 19 Server Action)
"use server";

import { z } from "zod";
import { redirect } from "next/navigation";

const schema = z.object({
  name: z.string().min(2),
  email: z.string().email(),
  message: z.string().min(10),
});

export async function submitContactAction(
  _prevState: unknown,
  formData: FormData
) {
  const result = schema.safeParse({
    name: formData.get("name"),
    email: formData.get("email"),
    message: formData.get("message"),
  });

  if (!result.success) {
    return {
      errors: result.error.flatten().fieldErrors,
      values: Object.fromEntries(formData),
    };
  }

  await saveContact(result.data);
  redirect("/contact/success");
}
typescriptcomponents/ContactFormAction.tsx (useActionState)
"use client";

import { useActionState } from "react";
import { submitContactAction } from "@/app/contact/actions";

export function ContactFormAction() {
  const [state, action, pending] = useActionState(submitContactAction, null);

  return (
    <form action={action} noValidate>
      <div>
        <label htmlFor="name">Name</label>
        <input
          id="name"
          name="name"
          defaultValue={(state?.values?.name as string) ?? ""}
          aria-describedby={state?.errors?.name ? "name-error" : undefined}
          aria-invalid={!!state?.errors?.name}
        />
        {state?.errors?.name && (
          <p id="name-error" role="alert">{state.errors.name[0]}</p>
        )}
      </div>

      <div>
        <label htmlFor="email">Email</label>
        <input
          id="email"
          name="email"
          type="email"
          defaultValue={(state?.values?.email as string) ?? ""}
          aria-describedby={state?.errors?.email ? "email-error" : undefined}
          aria-invalid={!!state?.errors?.email}
        />
        {state?.errors?.email && (
          <p id="email-error" role="alert">{state.errors.email[0]}</p>
        )}
      </div>

      <button type="submit" disabled={pending}>
        {pending ? "Sending..." : "Send message"}
      </button>
    </form>
  );
}
DimensionControlled (useState)Uncontrolled (refs / FormData)Server Actions (React 19)
Re-renders per keystrokeOne per field changeNoneNone during input
Real-time validationStraightforwardRequires manual onChangeOn submit only (unless enhanced)
Progressive enhancementRequires JSPartialWorks without JS
Best forInteractive forms with live feedbackFile uploads, large forms without live validationServer-heavy flows, simple mutations
03 / React Hook Form

React Hook Form

React Hook Form (RHF) is the standard for production form management in React. It uses an uncontrolled-by-default approach with ref-based registration, which means form inputs do not re-render on every keystroke. Only the inputs that are actively subscribed to a value re-render, and the overall form component only re-renders on submit or when validation state changes. For forms with many fields, this is a substantial performance improvement.

Core API

typescriptcomponents/RegistrationForm.tsx (core RHF)
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";

const registrationSchema = z.object({
  firstName: z.string().min(1, "First name is required"),
  lastName: z.string().min(1, "Last name is required"),
  email: z.string().email("Enter a valid email address"),
  password: z
    .string()
    .min(8, "Password must be at least 8 characters")
    .regex(/[A-Z]/, "Password must contain an uppercase letter")
    .regex(/[0-9]/, "Password must contain a number"),
  confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
  message: "Passwords do not match",
  path: ["confirmPassword"],
});

type RegistrationFormData = z.infer<typeof registrationSchema>;

export function RegistrationForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting, isSubmitSuccessful },
    setError,
    reset,
  } = useForm<RegistrationFormData>({
    resolver: zodResolver(registrationSchema),
    defaultValues: {
      firstName: "",
      lastName: "",
      email: "",
      password: "",
      confirmPassword: "",
    },
  });

  async function onSubmit(data: RegistrationFormData) {
    try {
      await registerUser(data);
      reset();
    } catch (err) {
      if (err instanceof ApiError && err.code === "EMAIL_TAKEN") {
        setError("email", { message: "This email is already registered" });
      } else {
        setError("root", { message: "Registration failed. Please try again." });
      }
    }
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)} noValidate>
      {errors.root && (
        <div role="alert" aria-live="assertive">
          {errors.root.message}
        </div>
      )}

      <div>
        <label htmlFor="firstName">First name</label>
        <input
          id="firstName"
          {...register("firstName")}
          aria-describedby={errors.firstName ? "firstName-error" : undefined}
          aria-invalid={!!errors.firstName}
        />
        {errors.firstName && (
          <p id="firstName-error" role="alert">{errors.firstName.message}</p>
        )}
      </div>

      {/* Additional fields follow the same pattern */}

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? "Creating account..." : "Create account"}
      </button>

      {isSubmitSuccessful && <p role="status">Account created successfully.</p>}
    </form>
  );
}

FormProvider and useFormContext

For forms with deeply nested field components, FormProvider distributes the form context so child components can access register, formState, and other methods without prop drilling.

typescriptcomponents/CheckoutForm.tsx (FormProvider pattern)
import { useForm, FormProvider } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { AddressFields } from "./AddressFields";
import { PaymentFields } from "./PaymentFields";
import { checkoutSchema, type CheckoutFormData } from "@/lib/schemas/checkout";

export function CheckoutForm() {
  const methods = useForm<CheckoutFormData>({
    resolver: zodResolver(checkoutSchema),
    defaultValues: { shipping: {}, billing: {}, payment: {} },
  });

  return (
    <FormProvider {...methods}>
      <form onSubmit={methods.handleSubmit(onSubmit)} noValidate>
        <fieldset>
          <legend>Shipping address</legend>
          <AddressFields prefix="shipping" />
        </fieldset>
        <fieldset>
          <legend>Payment details</legend>
          <PaymentFields />
        </fieldset>
        <button type="submit" disabled={methods.formState.isSubmitting}>
          Place order
        </button>
      </form>
    </FormProvider>
  );
}

// components/AddressFields.tsx — consumes context, no prop drilling
import { useFormContext } from "react-hook-form";
import type { CheckoutFormData } from "@/lib/schemas/checkout";

interface AddressFieldsProps {
  prefix: "shipping" | "billing";
}

export function AddressFields({ prefix }: AddressFieldsProps) {
  const { register, formState: { errors } } = useFormContext<CheckoutFormData>();
  const fieldErrors = errors[prefix];

  return (
    <>
      <div>
        <label htmlFor={`${prefix}.line1`}>Street address</label>
        <input
          id={`${prefix}.line1`}
          {...register(`${prefix}.line1`)}
          aria-invalid={!!fieldErrors?.line1}
          aria-describedby={fieldErrors?.line1 ? `${prefix}-line1-error` : undefined}
        />
        {fieldErrors?.line1 && (
          <p id={`${prefix}-line1-error`} role="alert">
            {fieldErrors.line1.message}
          </p>
        )}
      </div>
    </>
  );
}

Controller for Third-Party Components

typescriptcomponents/fields/SelectField.tsx (Controller pattern)
import { Controller, useFormContext } from "react-hook-form";
import Select from "react-select";

interface Option {
  value: string;
  label: string;
}

interface SelectFieldProps {
  name: string;
  label: string;
  options: Option[];
  required?: boolean;
}

// Controller bridges RHF's ref-based system with third-party controlled components
// that manage their own internal state (react-select, date pickers, etc.)
export function SelectField({ name, label, options, required }: SelectFieldProps) {
  const { control, formState: { errors } } = useFormContext();
  const error = errors[name];

  return (
    <div>
      <label htmlFor={name}>
        {label}
        {required && <span aria-hidden="true"> *</span>}
      </label>
      <Controller
        name={name}
        control={control}
        render={({ field }) => (
          <Select
            inputId={name}
            options={options}
            value={options.find((o) => o.value === field.value) ?? null}
            onChange={(opt) => field.onChange(opt?.value ?? "")}
            onBlur={field.onBlur}
            aria-invalid={!!error}
            aria-describedby={error ? `${name}-error` : undefined}
          />
        )}
      />
      {error && (
        <p id={`${name}-error`} role="alert">
          {error.message as string}
        </p>
      )}
    </div>
  );
}
04 / Validation

Schema Validation with Zod

Zod is the standard for runtime validation in TypeScript React applications. When combined with RHF via @hookform/resolvers/zod, the schema becomes the single source of truth for both validation rules and TypeScript types. You write the schema once, and both the form and the TypeScript compiler enforce it.

Schemas as the Source of Truth

typescriptlib/schemas/profile.ts
import { z } from "zod";

// Base schema — shared between create and edit forms
const profileBaseSchema = z.object({
  displayName: z
    .string()
    .min(2, "Display name must be at least 2 characters")
    .max(50, "Display name cannot exceed 50 characters"),
  bio: z.string().max(280, "Bio cannot exceed 280 characters").optional(),
  website: z
    .string()
    .url("Enter a valid URL including https://")
    .optional()
    .or(z.literal("")),
  timezone: z.enum(
    ["UTC", "America/New_York", "America/Los_Angeles", "Europe/London", "Australia/Melbourne"],
    { errorMap: () => ({ message: "Select a valid timezone" }) }
  ),
  notifications: z.object({
    email: z.boolean(),
    push: z.boolean(),
    digest: z.enum(["daily", "weekly", "never"]),
  }),
});

// Create schema adds password fields
export const createProfileSchema = profileBaseSchema.extend({
  email: z.string().email("Enter a valid email address"),
  password: z
    .string()
    .min(8, "Password must be at least 8 characters")
    .regex(/[^a-zA-Z0-9]/, "Password must contain a special character"),
  confirmPassword: z.string(),
}).refine(
  (data) => data.password === data.confirmPassword,
  { message: "Passwords do not match", path: ["confirmPassword"] }
);

// Edit schema omits password — same validation, different shape
export const editProfileSchema = profileBaseSchema;

// Type inference — no manual interface declarations needed
export type CreateProfileInput = z.infer<typeof createProfileSchema>;
export type EditProfileInput = z.infer<typeof editProfileSchema>;

Discriminated Unions for Conditional Validation

typescriptlib/schemas/payment.ts
import { z } from "zod";

// Different validation rules based on payment method selection
const creditCardSchema = z.object({
  method: z.literal("credit_card"),
  cardNumber: z
    .string()
    .regex(/^d{16}$/, "Card number must be 16 digits"),
  expiryMonth: z.string().regex(/^(0[1-9]|1[0-2])$/, "Invalid month"),
  expiryYear: z.string().regex(/^d{4}$/, "Invalid year"),
  cvv: z.string().regex(/^d{3,4}$/, "CVV must be 3 or 4 digits"),
});

const bankTransferSchema = z.object({
  method: z.literal("bank_transfer"),
  bsb: z.string().regex(/^d{6}$/, "BSB must be 6 digits"),
  accountNumber: z.string().regex(/^d{6,10}$/, "Enter a valid account number"),
});

const paypalSchema = z.object({
  method: z.literal("paypal"),
  paypalEmail: z.string().email("Enter the email address for your PayPal account"),
});

// Discriminated union: Zod validates the correct branch based on 'method'
export const paymentSchema = z.discriminatedUnion("method", [
  creditCardSchema,
  bankTransferSchema,
  paypalSchema,
]);

export type PaymentFormData = z.infer<typeof paymentSchema>;

Custom Refinements and Transforms

typescriptlib/schemas/date-range.ts
import { z } from "zod";

export const dateRangeSchema = z
  .object({
    startDate: z.string().regex(/^d{4}-d{2}-d{2}$/, "Enter a valid date"),
    endDate: z.string().regex(/^d{4}-d{2}-d{2}$/, "Enter a valid date"),
    maxDays: z.number().optional(),
  })
  .refine(
    (data) => new Date(data.endDate) >= new Date(data.startDate),
    { message: "End date must be on or after the start date", path: ["endDate"] }
  )
  .refine(
    (data) => {
      if (!data.maxDays) return true;
      const diff =
        (new Date(data.endDate).getTime() - new Date(data.startDate).getTime()) /
        (1000 * 60 * 60 * 24);
      return diff <= data.maxDays;
    },
    { message: "Date range exceeds the maximum allowed period", path: ["endDate"] }
  )
  .transform(({ maxDays: _maxDays, ...rest }) => rest); // strip internal field before submission
Share schemas between client and server. The same Zod schema that drives your RHF resolver can validate your API route handler or Server Action. Place shared schemas in a lib/schemas/ directory that is imported by both client components and server code. You get consistent error messages and zero schema drift between layers.
05 / State Management

Form State Management

RHF manages its own internal form state (values, errors, dirty fields, touched state, submission status) without React state. Understanding how to read and interact with that state — and when to lift it outside the form — is key to building predictable form behaviour.

formState Properties

PropertyTypeUse when
errorsFieldErrors<T>Rendering field-level error messages
isSubmittingbooleanDisabling the submit button during the async submit call
isValidbooleanEnabling the submit button only when the form passes validation (use with mode: “onChange”)
isDirtybooleanShowing an unsaved-changes warning before navigating away
dirtyFieldsPartial<Record<keyof T, boolean>>Submitting only changed fields (PATCH vs PUT semantics)
touchedFieldsPartial<Record<keyof T, boolean>>Showing validation only after a field has been interacted with
isSubmitSuccessfulbooleanShowing a success message after submission

Watch and Subscribe Patterns

typescriptcomponents/ConditionalFields.tsx (watch pattern)
import { useFormContext, useWatch } from "react-hook-form";
import type { ProfileFormData } from "@/lib/schemas/profile";

// useWatch creates a subscription to a specific field value.
// Only this component re-renders when accountType changes —
// not the entire form.
export function AccountTypeFields() {
  const { register } = useFormContext<ProfileFormData>();
  const accountType = useWatch<ProfileFormData, "accountType">({ name: "accountType" });

  return (
    <>
      <div>
        <label htmlFor="accountType">Account type</label>
        <select id="accountType" {...register("accountType")}>
          <option value="personal">Personal</option>
          <option value="business">Business</option>
        </select>
      </div>

      {accountType === "business" && (
        <>
          <div>
            <label htmlFor="companyName">Company name</label>
            <input id="companyName" {...register("companyName")} />
          </div>
          <div>
            <label htmlFor="abn">ABN</label>
            <input id="abn" {...register("abn")} />
          </div>
        </>
      )}
    </>
  );
}

Default Values and Reset

typescriptcomponents/EditProfileForm.tsx (async defaults)
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import { editProfileSchema, type EditProfileInput } from "@/lib/schemas/profile";

export function EditProfileForm({ userId }: { userId: string }) {
  const { data: profile } = useQuery({
    queryKey: ["profile", userId],
    queryFn: () => fetchProfile(userId),
  });

  const { register, handleSubmit, reset, formState: { errors, isDirty } } =
    useForm<EditProfileInput>({
      resolver: zodResolver(editProfileSchema),
    });

  // Populate form when server data arrives (or when profile changes externally)
  useEffect(() => {
    if (profile) {
      reset({
        displayName: profile.displayName,
        bio: profile.bio ?? "",
        website: profile.website ?? "",
        timezone: profile.timezone,
        notifications: profile.notifications,
      });
    }
  }, [profile, reset]);

  // Only submit changed fields — PATCH semantics
  async function onSubmit(data: EditProfileInput) {
    await updateProfile(userId, data);
    // Reset to new values after save so isDirty resets to false
    reset(data);
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)} noValidate>
      {/* fields */}
      <button type="submit" disabled={!isDirty}>
        Save changes
      </button>
      {isDirty && <p>You have unsaved changes.</p>}
    </form>
  );
}
06 / Dynamic Forms

Dynamic Forms

Dynamic forms have a variable number of fields determined at runtime: lists of items, repeating sections, or fields that appear and disappear based on user selections. RHF's useFieldArray handles list-based dynamics; conditional rendering with useWatch handles appearance-based dynamics.

useFieldArray for List Fields

typescriptcomponents/InvoiceLineItems.tsx (useFieldArray)
import { useFieldArray, useFormContext } from "react-hook-form";
import type { InvoiceFormData } from "@/lib/schemas/invoice";

export function InvoiceLineItems() {
  const { register, control, formState: { errors } } = useFormContext<InvoiceFormData>();

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

  return (
    <fieldset>
      <legend>Line items</legend>

      {fields.map((field, index) => (
        <div key={field.id} role="group" aria-label={`Line item ${index + 1}`}>
          <div>
            <label htmlFor={`lineItems.${index}.description`}>Description</label>
            <input
              id={`lineItems.${index}.description`}
              {...register(`lineItems.${index}.description`)}
              aria-invalid={!!errors.lineItems?.[index]?.description}
            />
            {errors.lineItems?.[index]?.description && (
              <p role="alert">{errors.lineItems[index].description?.message}</p>
            )}
          </div>

          <div>
            <label htmlFor={`lineItems.${index}.quantity`}>Qty</label>
            <input
              id={`lineItems.${index}.quantity`}
              type="number"
              min="1"
              {...register(`lineItems.${index}.quantity`, { valueAsNumber: true })}
            />
          </div>

          <div>
            <label htmlFor={`lineItems.${index}.unitPrice`}>Unit price</label>
            <input
              id={`lineItems.${index}.unitPrice`}
              type="number"
              min="0"
              step="0.01"
              {...register(`lineItems.${index}.unitPrice`, { valueAsNumber: true })}
            />
          </div>

          <button
            type="button"
            onClick={() => remove(index)}
            aria-label={`Remove line item ${index + 1}`}
          >
            Remove
          </button>
        </div>
      ))}

      <button
        type="button"
        onClick={() => append({ description: "", quantity: 1, unitPrice: 0 })}
      >
        Add line item
      </button>

      {errors.lineItems?.root && (
        <p role="alert">{errors.lineItems.root.message}</p>
      )}
    </fieldset>
  );
}

Schema-Driven Form Rendering

For enterprise contexts where forms are defined by a backend configuration (CMS fields, survey builders, dynamic checkout steps), a schema-driven approach renders fields from a descriptor object rather than hardcoded JSX. This pattern scales well when the field registry is typed and validated.

typescriptlib/form-renderer/types.ts
// Typed field descriptor — every field type has a strongly-typed config shape

type TextField = {
  type: "text" | "email" | "tel" | "url";
  name: string;
  label: string;
  placeholder?: string;
  required?: boolean;
  maxLength?: number;
};

type SelectField = {
  type: "select";
  name: string;
  label: string;
  options: Array<{ value: string; label: string }>;
  required?: boolean;
};

type CheckboxField = {
  type: "checkbox";
  name: string;
  label: string;
};

type TextareaField = {
  type: "textarea";
  name: string;
  label: string;
  rows?: number;
  maxLength?: number;
};

export type FieldDescriptor = TextField | SelectField | CheckboxField | TextareaField;

export interface FormDescriptor {
  fields: FieldDescriptor[];
  submitLabel?: string;
}
typescriptcomponents/DynamicFormRenderer.tsx
import { useForm } from "react-hook-form";
import type { FormDescriptor, FieldDescriptor } from "@/lib/form-renderer/types";

function renderField(field: FieldDescriptor, register: ReturnType<typeof useForm>["register"]) {
  const id = `field-${field.name}`;

  switch (field.type) {
    case "text":
    case "email":
    case "tel":
    case "url":
      return (
        <div key={field.name}>
          <label htmlFor={id}>{field.label}</label>
          <input
            id={id}
            type={field.type}
            placeholder={field.placeholder}
            {...register(field.name, { required: field.required })}
          />
        </div>
      );
    case "select":
      return (
        <div key={field.name}>
          <label htmlFor={id}>{field.label}</label>
          <select id={id} {...register(field.name, { required: field.required })}>
            <option value="">Select an option</option>
            {field.options.map((opt) => (
              <option key={opt.value} value={opt.value}>{opt.label}</option>
            ))}
          </select>
        </div>
      );
    case "checkbox":
      return (
        <div key={field.name}>
          <input id={id} type="checkbox" {...register(field.name)} />
          <label htmlFor={id}>{field.label}</label>
        </div>
      );
    case "textarea":
      return (
        <div key={field.name}>
          <label htmlFor={id}>{field.label}</label>
          <textarea id={id} rows={field.rows ?? 4} {...register(field.name)} />
        </div>
      );
  }
}

export function DynamicFormRenderer({ descriptor, onSubmit }: {
  descriptor: FormDescriptor;
  onSubmit: (data: Record<string, unknown>) => Promise<void>;
}) {
  const { register, handleSubmit } = useForm();

  return (
    <form onSubmit={handleSubmit(onSubmit)} noValidate>
      {descriptor.fields.map((field) => renderField(field, register))}
      <button type="submit">{descriptor.submitLabel ?? "Submit"}</button>
    </form>
  );
}
07 / Multi-Step Forms

Multi-Step Forms

Multi-step forms introduce state management complexity that goes beyond individual field values: step navigation, per-step validation, accumulated data across steps, and draft persistence. The key architectural decision is where step state lives and how data is accumulated as the user progresses.

Step State Machine with Zustand

typescriptstores/onboardingStore.ts
import { create } from "zustand";
import { persist } from "zustand/middleware";
import type { Step1Data, Step2Data, Step3Data } from "@/lib/schemas/onboarding";

type OnboardingData = Partial<Step1Data & Step2Data & Step3Data>;

interface OnboardingStore {
  currentStep: number;
  totalSteps: number;
  data: OnboardingData;
  completedSteps: Set<number>;

  setStepData: (step: number, data: Partial<OnboardingData>) => void;
  goToStep: (step: number) => void;
  nextStep: () => void;
  prevStep: () => void;
  reset: () => void;
}

export const useOnboardingStore = create<OnboardingStore>()(
  persist(
    (set, get) => ({
      currentStep: 1,
      totalSteps: 3,
      data: {},
      completedSteps: new Set(),

      setStepData: (step, stepData) =>
        set((state) => ({
          data: { ...state.data, ...stepData },
          completedSteps: new Set([...state.completedSteps, step]),
        })),

      goToStep: (step) => set({ currentStep: step }),

      nextStep: () =>
        set((state) => ({
          currentStep: Math.min(state.currentStep + 1, state.totalSteps),
        })),

      prevStep: () =>
        set((state) => ({
          currentStep: Math.max(state.currentStep - 1, 1),
        })),

      reset: () =>
        set({ currentStep: 1, data: {}, completedSteps: new Set() }),
    }),
    { name: "onboarding-draft" }  // persists to localStorage as a draft
  )
);

Per-Step Form Components

typescriptcomponents/onboarding/Step1PersonalInfo.tsx
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { step1Schema, type Step1Data } from "@/lib/schemas/onboarding";
import { useOnboardingStore } from "@/stores/onboardingStore";

export function Step1PersonalInfo() {
  const { data, setStepData, nextStep } = useOnboardingStore();

  const { register, handleSubmit, formState: { errors } } = useForm<Step1Data>({
    resolver: zodResolver(step1Schema),
    defaultValues: {
      firstName: data.firstName ?? "",
      lastName: data.lastName ?? "",
      dateOfBirth: data.dateOfBirth ?? "",
    },
  });

  function onValid(stepData: Step1Data) {
    setStepData(1, stepData);
    nextStep();
  }

  return (
    <form onSubmit={handleSubmit(onValid)} noValidate>
      <div>
        <label htmlFor="firstName">First name</label>
        <input id="firstName" {...register("firstName")} aria-invalid={!!errors.firstName} />
        {errors.firstName && <p role="alert">{errors.firstName.message}</p>}
      </div>

      <div>
        <label htmlFor="lastName">Last name</label>
        <input id="lastName" {...register("lastName")} aria-invalid={!!errors.lastName} />
        {errors.lastName && <p role="alert">{errors.lastName.message}</p>}
      </div>

      <button type="submit">Continue</button>
    </form>
  );
}
typescriptcomponents/onboarding/OnboardingWizard.tsx
import { useOnboardingStore } from "@/stores/onboardingStore";
import { Step1PersonalInfo } from "./Step1PersonalInfo";
import { Step2AccountDetails } from "./Step2AccountDetails";
import { Step3Preferences } from "./Step3Preferences";

const steps = [Step1PersonalInfo, Step2AccountDetails, Step3Preferences];

export function OnboardingWizard() {
  const { currentStep, totalSteps, completedSteps } = useOnboardingStore();
  const StepComponent = steps[currentStep - 1];

  return (
    <div>
      {/* Progress indicator */}
      <nav aria-label="Onboarding progress">
        <ol>
          {Array.from({ length: totalSteps }, (_, i) => i + 1).map((step) => (
            <li
              key={step}
              aria-current={step === currentStep ? "step" : undefined}
            >
              <span
                aria-label={
                  completedSteps.has(step)
                    ? `Step ${step}: completed`
                    : step === currentStep
                      ? `Step ${step}: current`
                      : `Step ${step}: upcoming`
                }
              >
                {step}
              </span>
            </li>
          ))}
        </ol>
      </nav>

      {/* Current step */}
      <div aria-live="polite" aria-atomic="true">
        <StepComponent />
      </div>
    </div>
  );
}
08 / Accessibility

Accessibility Patterns

Form accessibility is not optional. Forms are among the most common interaction surfaces on the web, and inaccessible forms exclude users who rely on screen readers, keyboard navigation, or other assistive technologies. The patterns below are the minimum production standard for any form in a React application.

Error Association

Every error message must be programmatically associated with the field it describes. Use aria-describedby pointing to the error element's id. Add aria-invalid="true" to the field when it has an error. These two attributes together give screen readers full context when the user navigates to or focuses on a field.

typescriptcomponents/ui/FormField.tsx (accessible field wrapper)
interface FormFieldProps {
  id: string;
  label: string;
  error?: string;
  hint?: string;
  required?: boolean;
  children: (props: {
    id: string;
    "aria-describedby"?: string;
    "aria-invalid"?: true;
    "aria-required"?: true;
  }) => React.ReactNode;
}

// Reusable wrapper that handles all ARIA associations automatically
export function FormField({ id, label, error, hint, required, children }: FormFieldProps) {
  const hintId = hint ? `${id}-hint` : undefined;
  const errorId = error ? `${id}-error` : undefined;
  const describedBy = [hintId, errorId].filter(Boolean).join(" ") || undefined;

  return (
    <div>
      <label htmlFor={id}>
        {label}
        {required && (
          <>
            <span aria-hidden="true"> *</span>
            <span className="sr-only"> (required)</span>
          </>
        )}
      </label>

      {hint && (
        <p id={hintId} className="field-hint">
          {hint}
        </p>
      )}

      {children({
        id,
        "aria-describedby": describedBy,
        "aria-invalid": error ? true : undefined,
        "aria-required": required ? true : undefined,
      })}

      {error && (
        <p id={errorId} role="alert" className="field-error">
          {error}
        </p>
      )}
    </div>
  );
}

Focus Management on Validation

typescripthooks/useFormFocusOnError.ts
import { useEffect, useRef } from "react";
import type { FieldErrors } from "react-hook-form";

// Moves focus to the first field with an error after a failed submit attempt.
// Essential for keyboard and screen reader users who need to know where errors are.
export function useFormFocusOnError<T extends Record<string, unknown>>(
  errors: FieldErrors<T>,
  submitCount: number
) {
  const prevSubmitCount = useRef(submitCount);

  useEffect(() => {
    if (submitCount === prevSubmitCount.current) return;
    prevSubmitCount.current = submitCount;

    const errorKeys = Object.keys(errors);
    if (errorKeys.length === 0) return;

    // Find the first errored input in DOM order
    const firstErrorField = document.querySelector<HTMLElement>(
      `[aria-invalid="true"], [name="${errorKeys[0]}"]`
    );

    firstErrorField?.focus();
  }, [errors, submitCount]);
}

Live Region Announcements

typescriptcomponents/ui/FormStatusAnnouncer.tsx
// Screen readers need live region announcements for async state changes
// that happen outside the user's current focus area.

interface FormStatusAnnouncerProps {
  isSubmitting: boolean;
  isSuccess: boolean;
  successMessage: string;
  errorMessage?: string;
}

export function FormStatusAnnouncer({
  isSubmitting,
  isSuccess,
  successMessage,
  errorMessage,
}: FormStatusAnnouncerProps) {
  return (
    <>
      {/* Polite: announced after the user finishes their current action */}
      <div
        role="status"
        aria-live="polite"
        aria-atomic="true"
        className="sr-only"
      >
        {isSubmitting && "Submitting form, please wait."}
        {isSuccess && successMessage}
      </div>

      {/* Assertive: interrupts immediately — only for critical errors */}
      <div
        role="alert"
        aria-live="assertive"
        aria-atomic="true"
        className="sr-only"
      >
        {errorMessage}
      </div>
    </>
  );
}

Required accessibility checks

  • Every input has a visible, programmatic label via htmlFor/id
  • Error messages use aria-describedby to associate with their field
  • Fields with errors have aria-invalid=“true”
  • Required fields are marked with aria-required=“true”
  • Focus moves to the first error field on failed submit
  • Submit success and failure are announced via live regions

Keyboard navigation checks

  • All interactive elements are reachable by Tab key
  • Tab order follows the visual reading order
  • Custom select, date picker, and combobox follow ARIA patterns
  • Modal dialogs trap focus correctly
  • Escape key closes modals and dismisses error toasts
  • Form can be fully submitted without a mouse
09 / Async Validation

Async Validation

Async validation checks fields against a server or external source: checking username availability, verifying a coupon code, or validating an email address against a suppression list. Done naively, it fires a request on every keystroke. Done well, it debounces, cancels in-flight requests, and communicates loading state clearly.

Debounced Async Field Validation

typescriptcomponents/fields/UsernameField.tsx
import { useFormContext } from "react-hook-form";
import { useState, useEffect, useRef } from "react";

export function UsernameField() {
  const { register, formState: { errors }, setError, clearErrors, watch } =
    useFormContext<{ username: string }>();

  const username = watch("username");
  const [checking, setChecking] = useState(false);
  const abortRef = useRef<AbortController | null>(null);

  useEffect(() => {
    if (!username || username.length < 3) {
      clearErrors("username");
      return;
    }

    // Cancel any previous in-flight check
    abortRef.current?.abort();
    abortRef.current = new AbortController();

    const timeout = setTimeout(async () => {
      setChecking(true);
      try {
        const res = await fetch(`/api/users/check-username?username=${username}`, {
          signal: abortRef.current!.signal,
        });
        const { available } = await res.json() as { available: boolean };

        if (!available) {
          setError("username", { type: "manual", message: "This username is already taken" });
        } else {
          clearErrors("username");
        }
      } catch (err) {
        if ((err as Error).name !== "AbortError") {
          // Silently fail async checks — do not block submit over a network error
        }
      } finally {
        setChecking(false);
      }
    }, 400); // 400ms debounce

    return () => {
      clearTimeout(timeout);
      abortRef.current?.abort();
    };
  }, [username, setError, clearErrors]);

  return (
    <div>
      <label htmlFor="username">
        Username
        {checking && <span aria-live="polite"> Checking availability...</span>}
      </label>
      <input
        id="username"
        {...register("username")}
        aria-invalid={!!errors.username}
        aria-describedby={errors.username ? "username-error" : undefined}
      />
      {errors.username && (
        <p id="username-error" role="alert">{errors.username.message}</p>
      )}
      {!errors.username && username && username.length >= 3 && !checking && (
        <p role="status" aria-live="polite">Username is available.</p>
      )}
    </div>
  );
}
Never block form submission on a failed async check. Network errors, rate limits, and timeouts can silently break async validation. Always allow submission to proceed if the async check could not complete. Re-validate on the server where the check is authoritative.
10 / Performance

Performance Optimization

Form performance matters at two levels: the number of React re-renders during user interaction, and the responsiveness of validation feedback. RHF is designed to minimise re-renders by default, but there are patterns that accidentally undo that benefit.

Subscription-Based Re-Renders

typescriptPerformance: right vs wrong subscription patterns
// WRONG: Watch at the form root — re-renders the entire form on every change
export function ProductForm() {
  const { register, watch } = useForm<ProductFormData>();
  const category = watch("category"); // subscribes the whole component to all changes

  return (
    <form>
      <input {...register("name")} />
      {/* Every keystroke in 'name' re-renders this entire component */}
      {category === "digital" && <DigitalOptions />}
    </form>
  );
}

// CORRECT: Isolate the subscription to the component that needs it
export function ProductForm() {
  const { register } = useForm<ProductFormData>();

  return (
    <form>
      <input {...register("name")} />
      <CategoryDependentFields />  {/* only this re-renders on category change */}
    </form>
  );
}

function CategoryDependentFields() {
  const category = useWatch<ProductFormData, "category">({ name: "category" });
  return category === "digital" ? <DigitalOptions /> : null;
}

Validation Mode Strategy

ModeWhen validation triggersRe-render costBest for
onSubmit (default)On submit only, then on change after first submitLowestSimple forms, server-dominant validation
onBlurWhen a field loses focusLowMost production forms — validates after interaction, not during
onChangeOn every keystrokeHigherPassword strength meters, character counters, live format feedback
allonChange and onBlurHighestUse sparingly — consider whether the UX benefit justifies the cost
typescriptRecommended: onBlur mode with reValidateMode onChange
// Validates on blur initially (less disruptive during typing),
// then re-validates on change once an error has appeared.
// This is the best UX/performance balance for most production forms.
const { register, handleSubmit, formState } = useForm<FormData>({
  resolver: zodResolver(schema),
  mode: "onBlur",
  reValidateMode: "onChange",
});

Large Form Optimisation

typescriptLazy section loading for large multi-section forms
import { lazy, Suspense, useState } from "react";

// For forms with many sections (e.g. a 40-field employee profile),
// lazy-load sections that are not immediately visible to reduce initial render cost
const EmploymentHistorySection = lazy(() => import("./sections/EmploymentHistorySection"));
const EducationSection = lazy(() => import("./sections/EducationSection"));
const ReferenceSection = lazy(() => import("./sections/ReferenceSection"));

export function EmployeeProfileForm() {
  const [openSection, setOpenSection] = useState<string | null>("personal");

  return (
    <form>
      <PersonalDetailsSection />

      <details open={openSection === "employment"} onToggle={() => setOpenSection("employment")}>
        <summary>Employment history</summary>
        <Suspense fallback={<SectionSkeleton />}>
          {openSection === "employment" && <EmploymentHistorySection />}
        </Suspense>
      </details>

      {/* Additional sections follow the same pattern */}
    </form>
  );
}
11 / Server Validation

Server-Side Validation

Client-side validation improves UX. Server-side validation is the security boundary. Never trust client-validated data. The server must re-validate every field with the same (or stricter) rules as the client. The architecture challenge is mapping server validation errors back to the correct form fields with good UX.

Server Actions with React 19

typescriptapp/settings/profile/actions.ts
"use server";

import { z } from "zod";
import { revalidatePath } from "next/cache";
import { editProfileSchema } from "@/lib/schemas/profile";
import { getServerSession } from "@/lib/auth";

export interface ProfileActionState {
  success: boolean;
  errors?: Partial<Record<string, string[]>>;
  message?: string;
}

export async function updateProfileAction(
  _prevState: ProfileActionState,
  formData: FormData
): Promise<ProfileActionState> {
  const session = await getServerSession();
  if (!session) return { success: false, message: "Unauthorised" };

  const raw = {
    displayName: formData.get("displayName"),
    bio: formData.get("bio"),
    website: formData.get("website"),
    timezone: formData.get("timezone"),
  };

  const result = editProfileSchema.safeParse(raw);

  if (!result.success) {
    return {
      success: false,
      errors: result.error.flatten().fieldErrors,
    };
  }

  try {
    await db.profile.update({
      where: { userId: session.userId },
      data: result.data,
    });
    revalidatePath("/settings/profile");
    return { success: true, message: "Profile updated successfully." };
  } catch (err) {
    return { success: false, message: "Failed to save profile. Please try again." };
  }
}

Mapping Server Errors to RHF Fields

typescripthooks/useServerFormErrors.ts
import { useEffect } from "react";
import type { UseFormSetError, FieldValues, Path } from "react-hook-form";

// After a failed server-side mutation, map the error response back to RHF fields.
// Works with both REST API error responses and Server Action state.

interface ServerErrors {
  fieldErrors?: Partial<Record<string, string[]>>;
  message?: string;
}

export function useServerFormErrors<T extends FieldValues>(
  serverErrors: ServerErrors | null,
  setError: UseFormSetError<T>
) {
  useEffect(() => {
    if (!serverErrors) return;

    if (serverErrors.fieldErrors) {
      for (const [field, messages] of Object.entries(serverErrors.fieldErrors)) {
        if (messages && messages.length > 0) {
          setError(field as Path<T>, {
            type: "server",
            message: messages[0],
          });
        }
      }
    }

    if (serverErrors.message && !serverErrors.fieldErrors) {
      setError("root" as Path<T>, {
        type: "server",
        message: serverErrors.message,
      });
    }
  }, [serverErrors, setError]);
}
12 / Optimistic Submissions

Optimistic Form Submissions

Optimistic submission updates the UI immediately as if the action succeeded, before the server responds. On success, nothing needs to change. On failure, the UI rolls back. The result is a snappy, responsive experience for common happy-path actions, with graceful degradation on error.

useOptimistic with Server Actions

typescriptcomponents/TodoItem.tsx (useOptimistic toggle)
"use client";

import { useOptimistic, useTransition } from "react";
import { toggleTodoAction } from "@/app/todos/actions";

interface Todo {
  id: string;
  text: string;
  completed: boolean;
}

export function TodoItem({ todo }: { todo: Todo }) {
  const [isPending, startTransition] = useTransition();
  const [optimisticTodo, updateOptimistic] = useOptimistic(
    todo,
    (current, completed: boolean) => ({ ...current, completed })
  );

  function handleToggle() {
    startTransition(async () => {
      // Optimistic update: show new state immediately
      updateOptimistic(!optimisticTodo.completed);
      // Server action: perform the real change
      await toggleTodoAction(todo.id, !todo.completed);
      // On success: React reconciles with real server state
      // On failure: React reverts to the original todo value
    });
  }

  return (
    <li>
      <label>
        <input
          type="checkbox"
          checked={optimisticTodo.completed}
          onChange={handleToggle}
          disabled={isPending}
          aria-label={optimisticTodo.text}
        />
        <span style={{ textDecoration: optimisticTodo.completed ? "line-through" : "none" }}>
          {optimisticTodo.text}
        </span>
      </label>
    </li>
  );
}

Optimistic Mutations with TanStack Query

typescripthooks/useUpdateProfile.ts
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { EditProfileInput } from "@/lib/schemas/profile";

export function useUpdateProfile(userId: string) {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (data: EditProfileInput) => updateProfileApi(userId, data),

    onMutate: async (newData) => {
      // Cancel any outgoing refetches to avoid overwriting optimistic update
      await queryClient.cancelQueries({ queryKey: ["profile", userId] });

      // Snapshot the previous value for rollback
      const previousProfile = queryClient.getQueryData(["profile", userId]);

      // Optimistically update the cache
      queryClient.setQueryData(["profile", userId], (old: unknown) => ({
        ...(old as object),
        ...newData,
      }));

      return { previousProfile };
    },

    onError: (_err, _newData, context) => {
      // Roll back to snapshot on error
      if (context?.previousProfile) {
        queryClient.setQueryData(["profile", userId], context.previousProfile);
      }
    },

    onSettled: () => {
      // Always refetch after mutation to ensure server-side truth
      queryClient.invalidateQueries({ queryKey: ["profile", userId] });
    },
  });
}
13 / Error Handling

Error Handling Architecture

Form errors come from three sources: client-side validation (immediate, before submission), server-side validation (returned with a submission response), and network or system errors (the submission itself failed to complete). Each category requires different UX treatment and a different mechanism to surface to the user.

Three-Tier Error Architecture

typescriptcomponents/ProfileForm.tsx (three-tier error handling)
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useUpdateProfile } from "@/hooks/useUpdateProfile";
import { editProfileSchema, type EditProfileInput } from "@/lib/schemas/profile";

export function ProfileForm({ userId }: { userId: string }) {
  const { mutateAsync } = useUpdateProfile(userId);

  const {
    register,
    handleSubmit,
    setError,
    formState: { errors, isSubmitting },
  } = useForm<EditProfileInput>({
    resolver: zodResolver(editProfileSchema), // Tier 1: client validation
  });

  async function onSubmit(data: EditProfileInput) {
    try {
      await mutateAsync(data);
    } catch (err) {
      if (err instanceof ValidationError) {
        // Tier 2: server validation — map field errors back to RHF
        for (const [field, message] of Object.entries(err.fieldErrors)) {
          setError(field as keyof EditProfileInput, {
            type: "server",
            message: message as string,
          });
        }
      } else {
        // Tier 3: network / system error — not field-specific, shown at form level
        setError("root.serverError", {
          type: "server",
          message: "Unable to save your profile. Please check your connection and try again.",
        });
      }
    }
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)} noValidate>
      {/* Tier 3 error: shown above the form */}
      {errors.root?.serverError && (
        <div role="alert" aria-live="assertive">
          {errors.root.serverError.message}
        </div>
      )}

      <div>
        <label htmlFor="displayName">Display name</label>
        <input
          id="displayName"
          {...register("displayName")}
          aria-invalid={!!errors.displayName}
          aria-describedby={errors.displayName ? "displayName-error" : undefined}
        />
        {errors.displayName && (
          // Tier 1 and Tier 2 errors: shown inline below the field
          <p id="displayName-error" role="alert">
            {errors.displayName.message}
          </p>
        )}
      </div>

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? "Saving..." : "Save changes"}
      </button>
    </form>
  );
}

Error Normalisation from APIs

typescriptlib/form-errors.ts
// Normalise different server error shapes into a consistent format
// for use with RHF's setError.

export interface NormalisedFormErrors {
  fieldErrors: Record<string, string>;
  formError?: string;
}

// REST API: { errors: [{ field: "email", message: "Already registered" }] }
export function normaliseRestErrors(
  body: { errors?: Array<{ field: string; message: string }>; message?: string }
): NormalisedFormErrors {
  const fieldErrors: Record<string, string> = {};

  for (const error of body.errors ?? []) {
    fieldErrors[error.field] = error.message;
  }

  return {
    fieldErrors,
    formError: Object.keys(fieldErrors).length === 0 ? body.message : undefined,
  };
}

// Zod flatten format: { fieldErrors: { email: ["Already registered"] } }
export function normaliseZodErrors(
  body: { fieldErrors?: Record<string, string[]> }
): NormalisedFormErrors {
  const fieldErrors: Record<string, string> = {};

  for (const [field, messages] of Object.entries(body.fieldErrors ?? {})) {
    if (messages.length > 0) fieldErrors[field] = messages[0];
  }

  return { fieldErrors };
}

// Apply to RHF
export function applyNormalisedErrors<T extends Record<string, unknown>>(
  errors: NormalisedFormErrors,
  setError: (field: string, error: { type: string; message: string }) => void
) {
  for (const [field, message] of Object.entries(errors.fieldErrors)) {
    setError(field, { type: "server", message });
  }
  if (errors.formError) {
    setError("root.serverError", { type: "server", message: errors.formError });
  }
}
14 / Anti-Patterns

Anti-Patterns

The following patterns appear frequently in React form codebases and each carries a concrete cost: performance regressions, broken accessibility, poor UX, or impossible-to-maintain code.

Validating on Every Keystroke Without Cause

typescriptAnti-pattern: onChange validation everywhere
// ANTI-PATTERN
// mode: "onChange" fires full Zod schema validation on every keystroke.
// On a large form, this means running every refine and transform on every key press.
// The result: input lag, unnecessary error flashing, and a poor user experience.
const { register } = useForm({ mode: "onChange" });

// CORRECT: Validate on blur initially; re-validate on change only after first error
const { register } = useForm({
  mode: "onBlur",
  reValidateMode: "onChange",
});

Storing Form State in Global State

typescriptAnti-pattern: form values in Zustand
// ANTI-PATTERN
// Syncing every field change to a global store creates unnecessary global re-renders,
// couples the form to global state, and makes the form impossible to reuse.
const useFormStore = create((set) => ({
  name: "",
  email: "",
  setName: (name) => set({ name }),
  setEmail: (email) => set({ email }),
}));

function NameField() {
  const { name, setName } = useFormStore();
  return <input value={name} onChange={(e) => setName(e.target.value)} />;
}

// CORRECT: Form state lives in the form. Persist only when you need cross-session
// or cross-route access (multi-step forms, unsaved drafts).
// Use RHF for form state. Use Zustand only for the step coordinator in multi-step flows.

Missing Accessible Error Messages

typescriptAnti-pattern: visual-only error messages
// ANTI-PATTERN: Error is visually present but not accessible to screen readers.
// The input has no aria-invalid, the error has no id, and no aria-describedby links them.
{errors.email && (
  <p style={{ color: "red" }}>{errors.email.message}</p>
)}

// CORRECT: Full accessible association
<input
  id="email"
  {...register("email")}
  aria-invalid={!!errors.email}                          // tells screen reader field is invalid
  aria-describedby={errors.email ? "email-error" : undefined}  // links to error message
/>
{errors.email && (
  <p id="email-error" role="alert">                      // role="alert" announces immediately
    {errors.email.message}
  </p>
)}

God Form Components

typescriptAnti-pattern: one component does everything
// ANTI-PATTERN
// A single 300-line component handles layout, validation, submission,
// error display, and all conditional rendering. Untestable. Unmaintainable.
// Impossible for two engineers to work on without conflicts.
export function GiantCheckoutForm() {
  const [step, setStep] = useState(1);
  const [values, setValues] = useState({});
  const [errors, setErrors] = useState({});
  // ... 250 more lines of interleaved logic and JSX
}

// CORRECT: Compose from focused, single-responsibility pieces
export function CheckoutForm() {
  return (
    <FormProvider {...methods}>
      <form onSubmit={methods.handleSubmit(onSubmit)}>
        {step === 1 && <ShippingStep />}   // owns its own fields and validation display
        {step === 2 && <PaymentStep />}    // independently testable
        {step === 3 && <ReviewStep />}     // reads accumulated state, submits
      </form>
    </FormProvider>
  );
}

Duplicate Validation Logic

Defining validation rules separately on the client (in a Yup schema, in custom onChange handlers, or in component prop checks) and again on the server creates inevitable drift. One schema in one place is the only sustainable model. Define it as a Zod schema, share it between client and server, and derive TypeScript types from it.

15 / Vite SPA

Without Next.js

All the patterns in this guide (RHF, Zod, accessible error association, async validation, multi-step state) apply directly to Vite SPA projects. The only differences are in server integration: without Server Actions, form submission goes through a standard fetch or a mutation library like TanStack Query or TanStack Form.

TanStack Form (alternative to RHF)

TanStack Form is a newer form library with first-class TanStack Router integration and a type-safe field API. It is a strong alternative to RHF for projects already using the TanStack ecosystem. The validation story is identical: pair it with Zod via the Zod adapter.

typescriptcomponents/RegistrationForm.tsx (TanStack Form)
import { useForm } from "@tanstack/react-form";
import { zodValidator } from "@tanstack/zod-form-adapter";
import { z } from "zod";

const registrationSchema = z.object({
  email: z.string().email("Enter a valid email address"),
  password: z.string().min(8, "Password must be at least 8 characters"),
});

export function RegistrationForm() {
  const form = useForm({
    defaultValues: { email: "", password: "" },
    onSubmit: async ({ value }) => {
      await registerUser(value);
    },
    validatorAdapter: zodValidator(),
  });

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        form.handleSubmit();
      }}
      noValidate
    >
      <form.Field
        name="email"
        validators={{ onChange: registrationSchema.shape.email }}
      >
        {(field) => (
          <div>
            <label htmlFor="email">Email</label>
            <input
              id="email"
              type="email"
              value={field.state.value}
              onBlur={field.handleBlur}
              onChange={(e) => field.handleChange(e.target.value)}
              aria-invalid={!!field.state.meta.errors.length}
              aria-describedby={field.state.meta.errors.length ? "email-error" : undefined}
            />
            {field.state.meta.errors.length > 0 && (
              <p id="email-error" role="alert">{field.state.meta.errors[0]}</p>
            )}
          </div>
        )}
      </form.Field>

      <form.Subscribe selector={(state) => state.isSubmitting}>
        {(isSubmitting) => (
          <button type="submit" disabled={isSubmitting}>
            {isSubmitting ? "Creating account..." : "Create account"}
          </button>
        )}
      </form.Subscribe>
    </form>
  );
}

Submitting to a REST API from Vite SPA

typescriptcomponents/ProfileForm.tsx (RHF + TanStack Query mutation)
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
import { editProfileSchema, type EditProfileInput } from "@/lib/schemas/profile";
import { applyNormalisedErrors, normaliseRestErrors } from "@/lib/form-errors";

export function ProfileForm({ userId }: { userId: string }) {
  const {
    register,
    handleSubmit,
    setError,
    formState: { errors, isDirty, isSubmitting },
  } = useForm<EditProfileInput>({
    resolver: zodResolver(editProfileSchema),
  });

  const { mutateAsync } = useMutation({
    mutationFn: (data: EditProfileInput) =>
      fetch(`/api/users/${userId}/profile`, {
        method: "PATCH",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(data),
      }).then(async (res) => {
        if (!res.ok) throw await res.json();
        return res.json();
      }),
  });

  async function onSubmit(data: EditProfileInput) {
    try {
      await mutateAsync(data);
    } catch (err) {
      const normalised = normaliseRestErrors(err as { errors?: Array<{ field: string; message: string }>; message?: string });
      applyNormalisedErrors(normalised, setError);
    }
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)} noValidate>
      {errors.root?.serverError && (
        <div role="alert">{errors.root.serverError.message}</div>
      )}
      <div>
        <label htmlFor="displayName">Display name</label>
        <input
          id="displayName"
          {...register("displayName")}
          aria-invalid={!!errors.displayName}
        />
        {errors.displayName && <p role="alert">{errors.displayName.message}</p>}
      </div>
      <button type="submit" disabled={isSubmitting || !isDirty}>
        {isSubmitting ? "Saving..." : "Save changes"}
      </button>
    </form>
  );
}
Client validation and server validation are not interchangeable. Client validation is a UX layer. Server validation is a security boundary. Every field validated on the client must also be validated on the server with equal or stricter rules. Using the same Zod schema on both sides is not a shortcut — it is the correct architecture.