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.
Choosing the Right Approach
| Scale | Recommended approach | Key tools |
|---|---|---|
| Simple (1-5 fields, no async) | Controlled form with React state, or React 19 Server Action with useActionState | React, Zod for validation |
| Medium (5-20 fields, async validation, submit errors) | React Hook Form + Zod resolver | RHF, Zod, @hookform/resolvers |
| Complex (dynamic fields, multi-step, conditional logic) | React Hook Form + Zod + useFieldArray + step state machine | RHF, Zod, Zustand (for step state) |
| Enterprise (schema-driven, reusable across teams) | Form system built on RHF with a typed field registry | RHF, 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.
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
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.
"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");
}"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>
);
}| Dimension | Controlled (useState) | Uncontrolled (refs / FormData) | Server Actions (React 19) |
|---|---|---|---|
| Re-renders per keystroke | One per field change | None | None during input |
| Real-time validation | Straightforward | Requires manual onChange | On submit only (unless enhanced) |
| Progressive enhancement | Requires JS | Partial | Works without JS |
| Best for | Interactive forms with live feedback | File uploads, large forms without live validation | Server-heavy flows, simple mutations |
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
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.
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
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>
);
}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
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
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
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 submissionlib/schemas/ directory that is imported by both client components and server code. You get consistent error messages and zero schema drift between layers.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
| Property | Type | Use when |
|---|---|---|
| errors | FieldErrors<T> | Rendering field-level error messages |
| isSubmitting | boolean | Disabling the submit button during the async submit call |
| isValid | boolean | Enabling the submit button only when the form passes validation (use with mode: “onChange”) |
| isDirty | boolean | Showing an unsaved-changes warning before navigating away |
| dirtyFields | Partial<Record<keyof T, boolean>> | Submitting only changed fields (PATCH vs PUT semantics) |
| touchedFields | Partial<Record<keyof T, boolean>> | Showing validation only after a field has been interacted with |
| isSubmitSuccessful | boolean | Showing a success message after submission |
Watch and Subscribe Patterns
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
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>
);
}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
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.
// 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;
}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>
);
}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
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
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>
);
}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>
);
}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.
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
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
// 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
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
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>
);
}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
// 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
| Mode | When validation triggers | Re-render cost | Best for |
|---|---|---|---|
onSubmit (default) | On submit only, then on change after first submit | Lowest | Simple forms, server-dominant validation |
onBlur | When a field loses focus | Low | Most production forms — validates after interaction, not during |
onChange | On every keystroke | Higher | Password strength meters, character counters, live format feedback |
all | onChange and onBlur | Highest | Use sparingly — consider whether the UX benefit justifies the cost |
// 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
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>
);
}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
"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
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]);
}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
"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
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] });
},
});
}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
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
// 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 });
}
}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
// 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
// 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
// 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
// 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.
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.
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
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>
);
}