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.
pnpm add react-hook-form @hookform/resolvers zod| Library | When to use |
|---|---|
| react-hook-form + zod | Default 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:
"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:
| Layer | When it runs | What it catches | Tool |
|---|---|---|---|
| HTML native | On submit, on blur for pattern | Empty required fields, invalid email format, out-of-range numbers | required, type, min, max, pattern attributes |
| Zod schema (client) | On submit by default, or on change with mode: "onChange" | Business rules, cross-field validation, type mismatches | zodResolver(schema) |
| Server validation | On form submission | Data integrity, permission checks, duplicate detection | Zod in server action |
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:
| State | What it looks like | Implementation |
|---|---|---|
| Default | Empty form, ready for input | Initial render |
| Filling | User is typing, no validation triggered yet | Normal input state |
| Validation error | Field is invalid, error message visible | errors.field, aria-invalid, role="alert" |
| Submitting | Button disabled, loading indicator shown | isSubmitting, aria-busy, disabled button |
| Submission error | Server returned an error (duplicate, permission) | error state from the mutation hook |
| Success | Form submitted, show confirmation or redirect | Success toast, navigation, or inline message |
"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" }
}
}"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-describedbyto 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
requiredattribute for native validation + screen reader hints
<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 callMulti-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:
| State | Visual | Accessibility |
|---|---|---|
| Disabled | Greyed out, not interactive | disabled attribute, aria-disabled |
| Submitting | Button shows spinner, form still visible | aria-busy="true", button disabled |
| Read-only | Normal appearance, not editable | readOnly 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.
Design System Integration
Work with existing component libraries — shadcn/ui, Radix, Base UI, and others — to accelerate development without sacrificing design quality or customization.
State Management
Choose the right state layer for each type of state. Most state should live closer to the server than you think.