Forms in React
Controlled vs uncontrolled, FormData, useActionState, useFormStatus, accessible errors.
Updated
What a form means in React
A form has three things to manage: the fields' values, the validation errors and the submission state (pending, success, a server error). React gives you two fundamental ways to hold the values, and every form library is built on one of them.
The HTML basics (name, label, input types, native validation) are in the HTML forms lesson — here we build on top of them.
Controlled vs uncontrolled
The difference between a controlled input and an uncontrolled one:
| Controlled | Uncontrolled | |
|---|---|---|
| Where the value lives | in React state (useState) |
in the DOM (the input holds it) |
| How you read it | from the variable, any time | on submit: FormData or a ref |
| Re-render on every keystroke | yes | no |
| Code | value + onChange on every field |
just name (+ defaultValue) |
| When | the value affects the UI in real time: live search, dependent fields, formatting (phone, card) | most forms: login, contact, settings |
// controlled
const [email, setEmail] = useState('')
<input value={email} onChange={e => setEmail(e.target.value)} />
// uncontrolled
<input name="email" defaultValue={user.email} />value without onChange = a locked (read-only) input. defaultValue = the initial value, then the DOM takes over.
Reading the data: FormData
function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
const form = new FormData(e.currentTarget)
const email = String(form.get('email'))
const tags = form.getAll('tags') // checkboxes with the same name → an array
const data = Object.fromEntries(form) // ⚠️ keeps only the last value for repeated names
}FormData values are always strings (or a File). A checked checkbox sends 'on', an unchecked one doesn't appear at all.
React 19: forms with actions
React 19 lets you pass a function directly to action. React takes care of preventDefault, of FormData and of the pending state.
'use client'
import { useActionState } from 'react'
import { useFormStatus } from 'react-dom'
type State = { error?: string; ok?: boolean }
async function subscribe(prev: State, formData: FormData): Promise<State> {
const email = String(formData.get('email') ?? '')
if (!email.includes('@')) return { error: 'Invalid email' }
await api('/subscribe', { method: 'POST', body: formData })
return { ok: true }
}
function SubmitButton() {
const { pending } = useFormStatus() // reads the parent form's state
return <button disabled={pending}>{pending ? 'Sending…' : 'Subscribe'}</button>
}
export function Newsletter() {
const [state, action] = useActionState(subscribe, {})
return (
<form action={action}>
<input name="email" type="email" required />
{state.error && <p role="alert">{state.error}</p>}
<SubmitButton />
</form>
)
}| Hook | What it gives you | Where |
|---|---|---|
useActionState(fn, initial) |
[state, action, isPending] — the result of the last submission |
the component with the <form> |
useFormStatus() |
{ pending, data } of the parent form |
a child component of the form |
useOptimistic |
the UI updated before the response | lists, likes |
With a Server Action instead of the client function, the same form works even without JavaScript.
Accessible errors
An error has to be seen and heard:
<label htmlFor="email">Email</label>
<input id="email" name="email" aria-invalid={!!error} aria-describedby={error ? 'email-error' : undefined} />
{error && <p id="email-error" role="alert">{error}</p>}| Attribute | Role |
|---|---|
aria-invalid |
the screen reader announces "invalid"; you style it with aria-invalid:border-danger |
aria-describedby |
links the message to the field — it's read when the user reaches the field |
role="alert" / aria-live |
the message is announced as soon as it appears |
When you need a library
| The form has... | Without a library | With a library (React Hook Form / Conform) |
|---|---|---|
| 2–5 fields, simple validation | ✓ useActionState + FormData |
unnecessary |
| validation on blur / while typing, per-field messages | clunky | ✓ |
| dynamic lists (add / remove rows) | clunky | ✓ |
| a multi-step wizard, dependent fields | clunky | ✓ |
Summary
- Uncontrolled +
FormDatafor most forms; controlled when the value changes the UI live. - React 19:
action={fn},useActionStatefor the result,useFormStatusfor pending. - Errors:
aria-invalid+aria-describedby+role="alert".