Forms are the workhorses of web applications. They look simple; they're full of subtleties — validation timing, error states, accessibility, security, idempotency. Most form bugs come from missing one of these.
This page covers the patterns that work.
Catches errors immediately. Better UX (don't wait for server roundtrip). Provides immediate feedback.
<input type="email" required>
HTML5 validation handles common cases. JavaScript validates more complex rules.
Important: client-side validation is not security. Clients can send anything; the server must validate.
The actual security boundary. Reject anything invalid; never trust client validation.
Even if the client did validate, the server validates again. Both layers are needed.
Last line of defense. NOT NULL, UNIQUE, CHECK constraints. Catches anything that bypassed earlier validation.
For critical data integrity, all three layers should validate.
When to show errors matters for UX:
Show errors only when the user submits. Too late — they have to fix multiple errors at once.
Show errors when the user moves to the next field. Most ergonomic for most cases.
Show errors as user types. For specific cases (password strength, username availability), this is right. For most fields, it's annoying — errors appear before the user finishes typing.
The reasonable default: validate on blur; on submit, validate everything; for specific fields, real-time.
Errors should be:
<label for="email">Email</label>
<input id="email" type="email" aria-describedby="email-error" aria-invalid="true">
<div id="email-error" role="alert">Email is required</div>
Screen readers announce the error when the field is focused.
Prevent double-submission:
async function submit() {
setSubmitting(true);
try {
await api.post('/orders', data);
} finally {
setSubmitting(false);
}
}
Disable the submit button while submitting is true.
For network failures, retries can duplicate. Send an idempotency key:
const idempotencyKey = uuid();
await api.post('/orders', data, {
headers: { 'Idempotency-Key': idempotencyKey }
});
See IdempotencyPatterns.
Long-running submissions need progress indication. Spinners, progress bars, status messages.
After successful submission, tell the user. Either redirect (navigation = implicit success) or show a confirmation message.
When the server returns errors, map them back to the right fields. The user should see exactly which fields are wrong.
For React:
The dominant choice. Performant; minimal re-renders; good DX.
const { register, handleSubmit, formState: { errors } } = useForm();
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email', { required: 'Required' })} />
{errors.email && <span>{errors.email.message}</span>}
</form>
Older; still common. More re-renders than React Hook Form.
Schema validation libraries. Pair with React Hook Form for type-safe validation.
const schema = z.object({
email: z.string().email(),
age: z.number().min(18).max(120),
});
// React Hook Form integration
useForm({ resolver: zodResolver(schema) });
For Vue, Svelte, etc., similar libraries exist.
<input type="email" autocomplete="email">
autocomplete lets browsers autofill. Specify per field.
<input type="password" autocomplete="current-password">
<input type="password" autocomplete="new-password"> <!-- For sign-up -->
The autocomplete value matters for password managers.
<input type="number" min="0" max="100" step="1">
Constraints apply; mobile keyboards show numeric pad.
<input type="date">
Native date picker. For more control, custom components or libraries.
<input type="file" accept="image/*" multiple>
accept filters; multiple allows several files. Server-side validation still required.
For long forms (multi-step, complex data), save to local storage as user types:
// On change
localStorage.setItem('draft-order', JSON.stringify(formData));
// On load
const draft = JSON.parse(localStorage.getItem('draft-order') || '{}');
Recover from accidents (browser crash, accidental navigation away).
For long forms, break into steps:
Modern form libraries handle multi-step; manual implementation is also fine.
Cross-Site Request Forgery — another site submits a form on behalf of the logged-in user. Mitigations:
Most frameworks have CSRF protection built in.
Cross-Site Scripting — user input is reflected back without sanitization; attacker injects script.
Frameworks (React, Vue) escape by default. Don't use dangerouslySetInnerHTML without sanitization.
Server accepts more fields than expected. User submits extra fields; server saves them.
Always specify which fields you accept; reject the rest.