Form Handling and Validation
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.
The validation layers
Client-side validation
Catches errors immediately. Better UX (don't wait for server roundtrip). Provides immediate feedback.
```html
<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.
Server-side validation
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.
Database constraints
Last line of defense. NOT NULL, UNIQUE, CHECK constraints. Catches anything that bypassed earlier validation.
For critical data integrity, all three layers should validate.
Validation timing
When to show errors matters for UX:
On submit only
Show errors only when the user submits. Too late — they have to fix multiple errors at once.
On blur (when leaving a field)
Show errors when the user moves to the next field. Most ergonomic for most cases.
Real-time (on input)
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.
Error display
Errors should be:
- **Specific**: "Email is required" not "Invalid"
- **Inline**: next to the field that's wrong
- **Persistent**: stays visible while the field is invalid
- **Linked to the field**: aria-describedby for accessibility
- **Visually clear**: color + icon + text, not just color
```html
<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.
Submission patterns
Disable button while submitting
Prevent double-submission:
```javascript
async function submit() {
setSubmitting(true);
try {
await api.post('/orders', data);
} finally {
setSubmitting(false);
}
}
```
Disable the submit button while `submitting` is true.
Idempotency keys
For network failures, retries can duplicate. Send an idempotency key:
```javascript
const idempotencyKey = uuid();
await api.post('/orders', data, {
headers: { 'Idempotency-Key': idempotencyKey }
});
```
See [IdempotencyPatterns](IdempotencyPatterns).
Show progress
Long-running submissions need progress indication. Spinners, progress bars, status messages.
Success feedback
After successful submission, tell the user. Either redirect (navigation = implicit success) or show a confirmation message.
Error recovery
When the server returns errors, map them back to the right fields. The user should see exactly which fields are wrong.
Form libraries
For React:
React Hook Form
The dominant choice. Performant; minimal re-renders; good DX.
```jsx
const { register, handleSubmit, formState: { errors } } = useForm();
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email', { required: 'Required' })} />
{errors.email && <span>{errors.email.message}</span>}
</form>
```
Formik
Older; still common. More re-renders than React Hook Form.
Zod, Yup
Schema validation libraries. Pair with React Hook Form for type-safe validation.
```javascript
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.
Specific input types
```html
<input type="email" autocomplete="email">
```
`autocomplete` lets browsers autofill. Specify per field.
Password
```html
<input type="password" autocomplete="current-password">
<input type="password" autocomplete="new-password"> <!-- For sign-up -->
```
The autocomplete value matters for password managers.
Numbers
```html
<input type="number" min="0" max="100" step="1">
```
Constraints apply; mobile keyboards show numeric pad.
Dates
```html
<input type="date">
```
Native date picker. For more control, custom components or libraries.
Files
```html
<input type="file" accept="image/*" multiple>
```
`accept` filters; `multiple` allows several files. Server-side validation still required.
Auto-save
For long forms (multi-step, complex data), save to local storage as user types:
```javascript
// 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).
Multi-step forms
For long forms, break into steps:
- Each step validates before allowing next
- Progress indicator (step 2 of 5)
- Allow back to previous steps
- Save draft per step
Modern form libraries handle multi-step; manual implementation is also fine.
Common security issues
CSRF
Cross-Site Request Forgery — another site submits a form on behalf of the logged-in user. Mitigations:
- CSRF tokens on every state-changing request
- SameSite cookies
- Same-origin checks
Most frameworks have CSRF protection built in.
XSS
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.
Mass assignment
Server accepts more fields than expected. User submits extra fields; server saves them.
Always specify which fields you accept; reject the rest.
Common failure patterns
- **No client-side validation.** Submit fails; no immediate feedback.
- **No server-side validation.** Security hole.
- **Validating on every keystroke.** Annoying; errors appear before user finishes.
- **Vague error messages.** "Invalid input" doesn't help.
- **Lost form data on error.** Form clears; user re-types everything.
- **No accessibility.** Form fields without labels, errors not announced.
- **No CSRF protection.** State-changing forms are vulnerable.
Further Reading
- [WebAccessibilityGuide](WebAccessibilityGuide) — Forms must be accessible
- [TypeScriptFundamentals](TypeScriptFundamentals) — Type-safe form schemas
- [IdempotencyPatterns](IdempotencyPatterns) — Submission idempotency
- [FrontendDevelopment Hub](FrontendDevelopmentHub) — Cluster index