Forms & Validation
| Field | Value |
|---|---|
| Type | Agent Reference |
| Source | ~/.copilot/agents/_refs/expert-react-frontend-engineer/forms-and-validation.md |
| Description | Not specified |
Source Content
Forms & Validation
Zod — Validation Everywhere
Use for: All runtime validation — API responses, form data, URL params, environment variables, external data boundaries.
import { z } from 'zod'
// Define schemas first — types are derived, not writtenexport const userSchema = z.object({ id: z.string().uuid(), name: z.string().min(1, 'Name is required').max(100), email: z.string().email('Invalid email address'), role: z.enum(['admin', 'editor', 'viewer']), createdAt: z.string().datetime(),})
// Derive TS types FROM Zod — single source of truthexport type User = z.infer<typeof userSchema>
// API response validation — throws on bad dataexport async function fetchUsers(): Promise<User[]> { const response = await fetch('/api/users') const data = await response.json() return z.array(userSchema).parse(data)}
// Form schema may differ from API schemaexport const createUserFormSchema = userSchema .omit({ id: true, createdAt: true }) .extend({ password: z.string().min(8, 'Password must be at least 8 characters'), confirmPassword: z.string(), }) .refine((data) => data.password === data.confirmPassword, { message: 'Passwords must match', path: ['confirmPassword'], })
// URL search params — coerce strings from the URLexport const userFiltersSchema = z.object({ q: z.string().optional().default(''), role: z.enum(['admin', 'editor', 'viewer', 'all']).optional().default('all'), page: z.coerce.number().int().positive().optional().default(1), sort: z.enum(['name', 'email', 'createdAt']).optional().default('name'),})
export type UserFilters = z.infer<typeof userFiltersSchema>Rules:
- Schemas first, then
z.inferfor types — never the other way around .parse()at API layer (throw on invalid).safeParse()when graceful error handling is needed- Colocate schemas with their domain —
user.schema.tsnext touser.api.ts - TanStack Router route definitions validate search params via Zod directly
TanStack Router Search Param Validation
import { createFileRoute } from '@tanstack/react-router'import { userFiltersSchema } from '../user.schema'
export const Route = createFileRoute('/users')({ validateSearch: (search) => userFiltersSchema.parse(search), component: UsersPage,})Forms — TanStack Form (preferred for TanStack Router apps)
TanStack Form integrates tightly with TanStack Router and provides type-safe form state.
import { useForm } from '@tanstack/react-form'import { zodValidator } from '@tanstack/zod-form-adapter'import { createUserFormSchema } from './user.schema'
function CreateUserForm({ onSuccess }: { onSuccess: () => void }) { const createUser = useCreateUser()
const form = useForm({ defaultValues: { name: '', email: '', role: 'viewer' as const, password: '', confirmPassword: '' }, validatorAdapter: zodValidator(), validators: { onSubmit: createUserFormSchema, }, onSubmit: async ({ value }) => { await createUser.mutateAsync(value) onSuccess() }, })
return ( <form onSubmit={(e) => { e.preventDefault(); form.handleSubmit() }}> <form.Field name="name" validators={{ onChange: createUserFormSchema.shape.name }} children={(field) => ( <TextField label="Full name" value={field.state.value} onChange={(e) => field.handleChange(e.target.value)} onBlur={field.handleBlur} error={field.state.meta.errors[0]} /> )} /> <form.Subscribe selector={(s) => [s.canSubmit, s.isSubmitting]} children={([canSubmit, isSubmitting]) => ( <Button type="submit" disabled={!canSubmit || isSubmitting} aria-busy={isSubmitting}> {isSubmitting ? 'Creating…' : 'Create account'} </Button> )} /> </form> )}Forms — React Hook Form + zodResolver (alternative)
Use when TanStack Form isn’t already in the project or for simpler forms.
import { useForm } from 'react-hook-form'import { zodResolver } from '@hookform/resolvers/zod'import { createUserFormSchema, type CreateUserForm } from './user.schema'
function CreateUserForm({ onSuccess }: { onSuccess: () => void }) { const createUser = useCreateUser()
const { register, handleSubmit, formState: { errors, isSubmitting }, } = useForm<CreateUserForm>({ resolver: zodResolver(createUserFormSchema), })
return ( <form onSubmit={handleSubmit(async (data) => { await createUser.mutateAsync(data); onSuccess() })}> <TextField label="Full name" {...register('name')} error={errors.name?.message} /> <Button type="submit" disabled={isSubmitting} aria-busy={isSubmitting}> {isSubmitting ? 'Creating…' : 'Create account'} </Button> </form> )}Form UX Rules (mandatory)
- Always use persistent labels above inputs — never use placeholder text as the only label. Placeholder disappears on focus and is inaccessible to screen readers.
- Inline validation in real time, not only on submit. Show errors after blur.
- Mark optional fields, not required — fewer visual markers = less cognitive load.
- Submit buttons describe the action — “Create account”, “Save changes”, “Send message”, not “Submit”.
- Pre-fill known data and support browser autocomplete attributes (
autoComplete="email", etc.). - Associate error messages with their inputs via
aria-describedbyandaria-invalid.
// GOOD — accessible form field with error<div> <label htmlFor="email" className="ui-type-label"> Email address </label> <input id="email" type="email" autoComplete="email" aria-describedby={errors.email ? 'email-error' : undefined} aria-invalid={!!errors.email} {...register('email')} /> {errors.email && ( <p id="email-error" role="alert" className="ui-type-body-xs text-destructive"> {errors.email.message} </p> )}</div>React 19 Form Features
useActionState: Form submission state management (replaces manualuseStatefor pending/error)useFormStatus: Submit button pending states without prop drillinguseOptimistic: Optimistic UI updates during mutations
// React 19 — form actions with useActionStateimport { useActionState } from 'react'
function ContactForm() { const [state, submitAction, isPending] = useActionState( async (_prevState: FormState, formData: FormData) => { const result = contactSchema.safeParse({ name: formData.get('name'), email: formData.get('email'), message: formData.get('message'), }) if (!result.success) return { error: result.error.format() } await sendContactEmail(result.data) return { success: true } }, null )
return ( <form action={submitAction}> <TextField name="name" label="Name" /> <TextField name="email" label="Email" type="email" autoComplete="email" /> <SubmitButton isPending={isPending} /> </form> )}
// useFormStatus gives access to parent form's pending statefunction SubmitButton({ isPending }: { isPending: boolean }) { return ( <Button type="submit" disabled={isPending} aria-busy={isPending}> {isPending ? 'Sending…' : 'Send message'} </Button> )}