Step 16 of 21
Forms in React
Build forms that validate, submit, show pending and error states, and stay accessible, using React 19 actions and Zod.
Almost every app is a set of forms: sign-up, checkout, settings, search. A good form tells users what's wrong before they submit, keeps what they typed when something fails, can't be submitted twice and works with a keyboard and a screen reader. This lesson builds one step by step with React 19's form actions and Zod for validation, then shows when a form library is worth adding.
Controlled vs uncontrolled inputs
There are two ways to get a value out of an input.
A controlled input stores its value in React state and re-renders on every keystroke:
const [email, setEmail] = useState('')
return <input value={email} onChange={(e) => setEmail(e.target.value)} />An uncontrolled input keeps its own value in the DOM, like plain HTML. You read it when the form submits:
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
const data = new FormData(e.currentTarget)
const email = data.get('email') as string
}
return (
<form onSubmit={handleSubmit}>
<input name="email" type="email" defaultValue="" />
</form>
)Use controlled inputs when the UI must react to every keystroke: a live character counter, a search box that filters as you type, a field whose value depends on another. Use uncontrolled inputs everywhere else. They're less code, they don't re-render the form on each key, and the name attribute is all you need to collect values with FormData.
Let HTML do the first round of validation
Before writing any JavaScript, use the attributes the HTML essentials lesson introduced: required, type="email", minLength, min, max, pattern. The browser blocks submission and shows a message, for free, in the user's language.
Built-in validation is a first line, not the only one. Its messages can't be styled consistently, it can't express rules like "end date after start date", and it's trivial to bypass. The server must validate everything again. Client-side validation exists to help honest users, not to stop dishonest ones.
Form actions and useActionState
React 19 lets you pass a function as a form's action. React calls it with the form's FormData when the form submits, and handles preventDefault for you. useActionState wraps that function so you get back its latest result and whether it's still running:
import { useActionState } from 'react'
type State = { error?: string; ok?: boolean }
async function subscribe(_prev: State, formData: FormData): Promise<State> {
const res = await fetch('/api/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: formData.get('email') }),
})
if (!res.ok) return { error: 'Could not subscribe. Try again.' }
return { ok: true }
}
export function NewsletterForm() {
const [state, formAction, isPending] = useActionState(subscribe, {})
if (state.ok) return <p>Thanks, check your inbox.</p>
return (
<form action={formAction}>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" required />
<button disabled={isPending}>{isPending ? 'Subscribing…' : 'Subscribe'}</button>
{state.error && <p role="alert">{state.error}</p>}
</form>
)
}The action receives the previous state and the form data, and returns the next state. isPending is true from submit until the promise settles, which you use to disable the button and stop double submissions.
A deeply nested submit button can read the pending state without props, using useFormStatus from react-dom. It must be called from a component rendered inside the <form>:
import { useFormStatus } from 'react-dom'
function SubmitButton({ children }: { children: React.ReactNode }) {
const { pending } = useFormStatus()
return <button disabled={pending}>{pending ? 'Saving…' : children}</button>
}Validating with a schema
Hand-written if statements for every field drift out of sync with the server and with your types. A schema library describes the shape once and gives you both validation and a TypeScript type. Zod is the most common:
npm install zodimport { z } from 'zod'
export const signupSchema = z
.object({
name: z.string().trim().min(1, 'Enter your name'),
email: z.email('Enter a valid email address'),
password: z.string().min(12, 'Use at least 12 characters'),
confirm: z.string(),
})
.refine((v) => v.password === v.confirm, {
message: 'Passwords do not match',
path: ['confirm'],
})
export type Signup = z.infer<typeof signupSchema>This uses Zod 4, where z.email() is a top-level helper. safeParse never throws; it returns either the parsed data or a list of issues, each with the path of the field it belongs to:
type FieldErrors = Partial<Record<keyof Signup, string>>
function toFieldErrors(issues: z.ZodError['issues']): FieldErrors {
const errors: FieldErrors = {}
for (const issue of issues) {
const field = issue.path[0] as keyof Signup
errors[field] ??= issue.message // keep the first message per field
}
return errors
}Because the schema is plain TypeScript, you can import the same file on a Node server and validate there with identical rules.
Putting it together: a sign-up form
There's one trap with form actions: after an action finishes, React resets uncontrolled fields to their defaultValue. If validation fails and you don't plan for it, the user's input disappears. The fix is to return the submitted values in the state and feed them back as defaultValue.
import { useActionState } from 'react'
import { signupSchema, type Signup } from './signup-schema'
type State = {
values: Partial<Signup>
errors: Partial<Record<keyof Signup, string>>
formError?: string
}
async function signup(_prev: State, formData: FormData): Promise<State> {
const values = Object.fromEntries(formData) as Partial<Signup>
const parsed = signupSchema.safeParse(values)
if (!parsed.success) return { values, errors: toFieldErrors(parsed.error.issues) }
const res = await fetch('/api/signup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(parsed.data),
})
if (res.status === 409) return { values, errors: { email: 'That email is already registered' } }
if (!res.ok) return { values, errors: {}, formError: 'Something went wrong. Try again.' }
window.location.assign('/welcome')
return { values: {}, errors: {} }
}
export function SignupForm() {
const [state, formAction, isPending] = useActionState(signup, { values: {}, errors: {} })
return (
<form action={formAction} noValidate>
<Field label="Name" name="name" state={state} autoComplete="name" />
<Field label="Email" name="email" type="email" state={state} autoComplete="email" />
<Field label="Password" name="password" type="password" state={state} autoComplete="new-password" />
<Field label="Confirm password" name="confirm" type="password" state={state} autoComplete="new-password" />
{state.formError && <p role="alert">{state.formError}</p>}
<button disabled={isPending}>{isPending ? 'Creating account…' : 'Create account'}</button>
</form>
)
}noValidate turns off the browser's own bubbles so your schema's messages are the single source of feedback. The server's answer (409, email taken) is mapped onto the same field errors, so users see one consistent style of error whether it came from the client or the server.
Accessible errors
A red border means nothing to a screen-reader user, and nothing to someone who is colour-blind. Each field needs three things: a real <label>, aria-invalid when it has an error, and aria-describedby pointing at the error text so it's read aloud with the field.
type FieldProps = {
label: string
name: keyof Signup
type?: string
autoComplete?: string
state: State
}
function Field({ label, name, type = 'text', autoComplete, state }: FieldProps) {
const error = state.errors[name]
const errorId = `${name}-error`
return (
<div>
<label htmlFor={name}>{label}</label>
<input
id={name}
name={name}
type={type}
autoComplete={autoComplete}
defaultValue={type === 'password' ? undefined : state.values[name]}
aria-invalid={error ? true : undefined}
aria-describedby={error ? errorId : undefined}
/>
{error && <p id={errorId} className="field-error">{error}</p>}
</div>
)
}Two more details matter:
- Move focus to the first invalid field after a failed submit, so keyboard users land where the problem is. A
useEffectthat runs whenstate.errorschanges can callform.querySelector('[aria-invalid="true"]')?.focus(). - Don't validate on every keystroke. Telling someone their email is invalid after they've typed one letter is hostile. Validate on submit, and after a field has shown an error, re-check it on blur or change so the message clears as soon as it's fixed.
Notice also that passwords are not refilled: you should never echo a password back into the page.
When to reach for a form library
React's built-ins cover most forms. A library earns its place when forms get large and dynamic.
| Need | Built-in actions | React Hook Form / TanStack Form |
|---|---|---|
| A handful of fields, validate on submit | Ideal | Overkill |
| Per-field "touched" and "dirty" tracking | Manual | Built in |
| Dynamic field arrays (add/remove line items) | Manual and fiddly | Built in (useFieldArray) |
| Validate on blur, re-validate on change | Manual | A config option |
| Wizard with steps sharing state | Manual | Easier |
Both libraries accept a Zod schema through a resolver or adapter, so the schema you wrote above carries over unchanged.
Practice
Build a "create project" form with these fields: name (required, 3–60 characters), start date and end date (end must be on or after start), and visibility (public or private, radio buttons).
- Define the schema with Zod, including the date rule as a
.refinewithpath: ['endDate']. - Use
useActionState. Simulate the API with a function that waits 800 ms and rejects the name "taken" with a field error. - Keep entered values after a failed submit, disable the button while pending, and mark invalid fields with
aria-invalidandaria-describedby. - Move focus to the first invalid field after a failed submit.
Show solution
import { useActionState, useEffect, useRef } from 'react'
import { z } from 'zod'
const projectSchema = z
.object({
name: z.string().trim().min(3, 'At least 3 characters').max(60, 'At most 60 characters'),
startDate: z.iso.date('Pick a start date'),
endDate: z.iso.date('Pick an end date'),
visibility: z.enum(['public', 'private'], 'Choose a visibility'),
})
.refine((v) => v.endDate >= v.startDate, { message: 'End date must be on or after the start date', path: ['endDate'] })
type Project = z.infer<typeof projectSchema>
type State = { values: Partial<Project>; errors: Partial<Record<keyof Project, string>>; saved?: string }
const fakeApi = (p: Project) =>
new Promise<{ ok: boolean }>((resolve) => setTimeout(() => resolve({ ok: p.name.toLowerCase() !== 'taken' }), 800))
async function createProject(_prev: State, formData: FormData): Promise<State> {
const values = Object.fromEntries(formData) as Partial<Project>
const parsed = projectSchema.safeParse(values)
if (!parsed.success) {
const errors: State['errors'] = {}
for (const issue of parsed.error.issues) errors[issue.path[0] as keyof Project] ??= issue.message
return { values, errors }
}
const res = await fakeApi(parsed.data)
if (!res.ok) return { values, errors: { name: 'A project with this name already exists' } }
return { values: {}, errors: {}, saved: parsed.data.name }
}
export function CreateProjectForm() {
const [state, action, pending] = useActionState(createProject, { values: {}, errors: {} })
const formRef = useRef<HTMLFormElement>(null)
useEffect(() => {
formRef.current?.querySelector<HTMLElement>('[aria-invalid="true"]')?.focus()
}, [state.errors])
const describe = (field: keyof Project) =>
state.errors[field] ? { 'aria-invalid': true, 'aria-describedby': `${field}-error` } : {}
const error = (field: keyof Project) =>
state.errors[field] && <p id={`${field}-error`}>{state.errors[field]}</p>
return (
<form ref={formRef} action={action} noValidate>
{state.saved && <p role="status">Created “{state.saved}”.</p>}
<label htmlFor="name">Name</label>
<input id="name" name="name" defaultValue={state.values.name} {...describe('name')} />
{error('name')}
<label htmlFor="startDate">Start date</label>
<input id="startDate" name="startDate" type="date" defaultValue={state.values.startDate} {...describe('startDate')} />
{error('startDate')}
<label htmlFor="endDate">End date</label>
<input id="endDate" name="endDate" type="date" defaultValue={state.values.endDate} {...describe('endDate')} />
{error('endDate')}
<fieldset tabIndex={-1} {...describe('visibility')}>
<legend>Visibility</legend>
<label><input type="radio" name="visibility" value="public" defaultChecked={state.values.visibility === 'public'} /> Public</label>
<label><input type="radio" name="visibility" value="private" defaultChecked={state.values.visibility === 'private'} /> Private</label>
</fieldset>
{error('visibility')}
<button disabled={pending}>{pending ? 'Creating…' : 'Create project'}</button>
</form>
)
}Comparing ISO dates as strings ("2026-03-01" >= "2026-02-28") works because the YYYY-MM-DD format sorts lexicographically in date order. A fieldset isn't focusable by default, which is why it gets tabIndex={-1}: the effect can focus it, but Tab still skips it.
Key takeaways
- Prefer uncontrolled inputs read with
FormData; control an input only when the UI must react to each keystroke. - React 19 form actions plus
useActionStategive you the result of the last submit and anisPendingflag to block double submits. - Describe validation once with a schema such as Zod, use it on the client for feedback and on the server for safety.
- Actions reset uncontrolled fields, so return submitted values in state and feed them back as
defaultValue(never for passwords). - Accessible errors need a label,
aria-invalid,aria-describedbyand focus on the first invalid field.
Check yourself
Quiz
0/4 correct · 4 left
1.After a failed submit with useActionState, every field the user filled in is empty. What is the most likely cause?
2.Why validate on the server when the client already validates with the same Zod schema?
3.Which combination makes a field's error message reachable by screen-reader users?
4.When is a controlled input the better choice?
Finished reading?
Mark it complete to fill in your roadmap.