Design Engineering
Build

Forms & Input

Build production-grade forms with validation, error handling, loading states, and accessibility — from simple contact forms to complex multi-step workflows.

Forms are the most common way users interact with a web application. A well-built form handles validation, error feedback, loading states, and accessibility — all of which require explicit design and implementation. A poorly built form silently fails for more users than any other component type.

Choosing a Form Library

Start with react-hook-form for all but the simplest forms. It provides uncontrolled field registration, minimal re-renders, and a clean way to integrate Zod schemas for validation.

Dependencies
pnpm add react-hook-form @hookform/resolvers zod
LibraryWhen to use
react-hook-form + zodDefault choice for any form with validation
useActionState (React 19 native)Single-field forms, search inputs, simple actions with no validation
Plain HTML <form>Server-only forms with no client interactivity

Core Pattern

The standard pattern combines Zod schema validation with react-hook-form registration:

Basic form with Zod validation
"use client"

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

const projectSchema = z.object({
  name: z.string().min(1, "Project name is required").max(100),
  description: z.string().max(500).optional(),
})

type ProjectFormData = z.infer<typeof projectSchema>

export function ProjectForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<ProjectFormData>({
    resolver: zodResolver(projectSchema),
  })

  const onSubmit = async (data: ProjectFormData) => {
    // data is already type-checked by Zod
    await createProject(data)
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)} noValidate>
      <label htmlFor="name">Project name</label>
      <input id="name" {...register("name")} aria-invalid={!!errors.name} aria-describedby={errors.name ? "name-error" : undefined} />
      {errors.name && <p id="name-error" role="alert">{errors.name.message}</p>}

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? "Saving..." : "Create project"}
      </button>
    </form>
  )
}

Validation Strategies

Validation runs at three layers. Each catches something the others miss:

LayerWhen it runsWhat it catchesTool
HTML nativeOn submit, on blur for patternEmpty required fields, invalid email format, out-of-range numbersrequired, type, min, max, pattern attributes
Zod schema (client)On submit by default, or on change with mode: "onChange"Business rules, cross-field validation, type mismatcheszodResolver(schema)
Server validationOn form submissionData integrity, permission checks, duplicate detectionZod in server action
Cross-field validation with Zod
const signupSchema = z.object({
  password: z.string().min(8),
  confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
  message: "Passwords must match",
  path: ["confirmPassword"],
})

Form States

Every form has six distinct states. Each must be accounted for:

StateWhat it looks likeImplementation
DefaultEmpty form, ready for inputInitial render
FillingUser is typing, no validation triggered yetNormal input state
Validation errorField is invalid, error message visibleerrors.field, aria-invalid, role="alert"
SubmittingButton disabled, loading indicator shownisSubmitting, aria-busy, disabled button
Submission errorServer returned an error (duplicate, permission)error state from the mutation hook
SuccessForm submitted, show confirmation or redirectSuccess toast, navigation, or inline message
Server action with useActionState (React 19)
"use server"

import { z } from "zod"

const schema = z.object({
  email: z.string().email(),
})

export async function subscribe(prevState: { error?: string; success?: boolean }, formData: FormData) {
  const parsed = schema.safeParse({ email: formData.get("email") })
  if (!parsed.success) return { error: "Invalid email address" }

  try {
    await db.insert(newsletterSubscribers).values(parsed.data)
    return { success: true }
  } catch {
    return { error: "This email is already subscribed" }
  }
}
Client form using useActionState
"use client"

import { useActionState } from "react"
import { useFormStatus } from "react-dom"
import { subscribe } from "@/actions/subscribe"

function SubmitButton() {
  const { pending } = useFormStatus()
  return (
    <button type="submit" disabled={pending} aria-busy={pending}>
      {pending ? "Subscribing..." : "Subscribe"}
    </button>
  )
}

export function NewsletterForm() {
  const [state, formAction] = useActionState(subscribe, {})

  if (state.success) return <p>Thanks for subscribing!</p>

  return (
    <form action={formAction}>
      <label htmlFor="email">Email address</label>
      <input id="email" name="email" type="email" required aria-describedby={state.error ? "email-error" : undefined} aria-invalid={!!state.error} />
      {state.error && <p id="email-error" role="alert">{state.error}</p>}
      <SubmitButton />
    </form>
  )
}

For a deeper walkthrough of Server Functions, useActionState, and useFormStatus — including the full request lifecycle from form submission through Zod validation to database write — see the Component Development page. The patterns are complementary: that page covers the architectural decisions, this page covers the form-specific UX.

Accessibility Requirements

Every form control must meet these minimum requirements:

  • Associated <label>: Every input needs a <label htmlFor="id"> — placeholder text is not a label
  • Error association: Use aria-describedby to link error messages to their input
  • Error role: Error messages use role="alert" for immediate screen reader announcement
  • Invalid state: Use aria-invalid="true" on the input when validation fails
  • Busy state: Use aria-busy="true" on the submit button during submission
  • Required fields: Use required attribute for native validation + screen reader hints
Accessible form field pattern
<div>
  <label htmlFor="email">Email address</label>
  <input
    id="email"
    type="email"
    required
    aria-invalid={!!errors.email}
    aria-describedby={errors.email ? "email-error" : "email-hint"}
    {...register("email")}
  />
  <p id="email-hint">We will never share your email</p>
  {errors.email && (
    <p id="email-error" role="alert">{errors.email.message}</p>
  )}
</div>

UX Patterns

Inline validation

Validate on blur for individual fields. This gives immediate feedback without overwhelming the user:

useForm({ resolver: zodResolver(schema), mode: "onBlur" })

Debounced validation

For fields that trigger expensive checks (username availability), debounce the validation:

useForm({ resolver: zodResolver(schema), mode: "onChange" })
// Rate-limit in the resolver with a debounced API call

Multi-step forms

Split long forms into steps to reduce cognitive load:

// Track the current step in local state
const [step, setStep] = useState(1)

// Validate only the current step's fields
const stepSchema = schemas[step - 1]
const form = useForm({ resolver: zodResolver(stepSchema), mode: "onBlur" })

For multi-step forms, consider using @rife/formik-step or a simple useState with step-specific Zod schemas. Do not validate the entire form schema until the final step — partial schemas give better error feedback.

Disabled States

A disabled form is not the same as a submitted form. Each has different accessibility semantics:

StateVisualAccessibility
DisabledGreyed out, not interactivedisabled attribute, aria-disabled
SubmittingButton shows spinner, form still visiblearia-busy="true", button disabled
Read-onlyNormal appearance, not editablereadOnly attribute, aria-readonly="true"

Never use pointer-events: none without also disabling keyboard access. A button that cannot be clicked but can be tabbed to is a usability bug.

On this page