Skip to content

Forms & Validation

FieldValue
TypeAgent Reference
Source~/.copilot/agents/_refs/expert-react-frontend-engineer/forms-and-validation.md
DescriptionNot 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 written
export 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 truth
export type User = z.infer<typeof userSchema>
// API response validation — throws on bad data
export 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 schema
export 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 URL
export 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.infer for 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.ts next to user.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-describedby and aria-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 manual useState for pending/error)
  • useFormStatus: Submit button pending states without prop drilling
  • useOptimistic: Optimistic UI updates during mutations
// React 19 — form actions with useActionState
import { 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 state
function SubmitButton({ isPending }: { isPending: boolean }) {
return (
<Button type="submit" disabled={isPending} aria-busy={isPending}>
{isPending ? 'Sending…' : 'Send message'}
</Button>
)
}