Forms and validation
A good form works before JavaScript finishes loading, explains what went wrong, preserves useful input, and never trusts the browser as its security boundary.
Start with the form the browser already understands
HTML forms already know how to collect named fields and submit them. Server Actions fit that model well because a form can call server code through its action prop without building a separate client-side request layer first.
Use labels, names, sensible input types, autocomplete hints, and native constraints such as required where they help. Native validation is a useful first line of feedback, but the server must still validate every value. A request can be sent without your interface.
export default function ContactForm() {
return (
<form action={sendMessage}>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" autoComplete="email" required />
<label htmlFor="message">Message</label>
<textarea id="message" name="message" minLength={20} required />
<button type="submit">Send message</button>
</form>
)
}Parse untrusted input once on the server
FormData is intentionally loose. Fields may be missing, repeated, unexpectedly large, or shaped differently from what TypeScript suggests. Convert it into a domain object at the boundary and return clear field errors when parsing fails.
A schema library is helpful, but the important part is the boundary. The rest of the application should receive a known value such as CreateAccountInput, not raw FormData that gets interpreted differently in three places.
- Trim text before checking minimum length.
- Normalise email addresses only where your product rules allow it.
- Reject unknown enum values rather than silently choosing a default.
- Set size limits for text and uploaded files.
- Return field errors for expected mistakes and throw only for unexpected failures.
Pending state should answer what happens next
When a submission begins, disable only the control that would create an accidental duplicate. Keep the rest of the page readable. A useful pending label says what is happening, such as Saving profile, instead of the vague Loading.
React form hooks can expose pending state and the result returned by a Server Action. Put status messages in a live region so screen-reader users hear the same feedback that sighted users see.
'use client'
import { useFormStatus } from 'react-dom'
export function SubmitButton() {
const { pending } = useFormStatus()
return (
<button disabled={pending} aria-disabled={pending}>
{pending ? 'Saving profile…' : 'Save profile'}
</button>
)
}Use optimistic UI only when failure is easy to undo
A like button or a small reorder can feel instant with optimistic state. Payment, account deletion, and inventory reservation deserve a confirmed result. The question is not whether optimistic UI looks modern. The question is whether the interface can explain and recover from rejection without misleading the user.
Always keep a stable identifier for optimistic items, replace them with the server result, and remove or mark them when the write fails. Do not quietly leave invented state on screen.
Validation and permission checks solve different problems
Validation asks whether the input has an acceptable shape. Authorisation asks whether this user may perform the operation on this resource. A perfectly valid project ID is still dangerous if it belongs to somebody else.
Perform both checks inside the server operation. Then make the write idempotent where retries are possible, especially for payment callbacks, invitation acceptance, and other operations that should happen once.
Can you explain “Forms and validation” without reaching for the API reference?
Describe the main trade-off, one failure mode, and the decision you would make in a real project.