Forms & Validation Patterns
Forms account for the majority of user interaction bugs. The difference between a junior and senior approach: seniors make forms accessible, validated, and resilient by default, not as an afterthought.
Native HTML Validation
The browser provides a validation API that most engineers ignore in favor of JavaScript:
<form novalidate>
<label for="email">Email</label>
<input
id="email"
type="email"
required
pattern="[^@]+@[^@]+\.[^@]+"
minlength="5"
maxlength="254"
/>
<label for="age">Age</label>
<input id="age" type="number" min="18" max="120" required />
</form>| Attribute | Validates |
|---|---|
required | Field is not empty |
type="email" | Basic email format |
pattern | Regex match |
min / max | Numeric range |
minlength / maxlength | String length |
Constraint Validation API
const input = document.querySelector('#email');
input.validity.valid; // boolean
input.validity.typeMismatch; // true if type="email" fails
input.validity.patternMismatch;
input.validationMessage; // browser's default error string
input.setCustomValidity('Custom error message');Use novalidate on the <form> to suppress browser tooltips but still use the API programmatically. This gives you control over error presentation while keeping native validation logic.
Controlled Forms in React
React's controlled input pattern ties input state to component state:
function LoginForm() {
const [form, setForm] = useState({ email: '', password: '' });
const [errors, setErrors] = useState<Record<string, string>>({});
const validate = () => {
const next: Record<string, string> = {};
if (!form.email.includes('@')) next.email = 'Invalid email address';
if (form.password.length < 8) next.password = 'Must be at least 8 characters';
setErrors(next);
return Object.keys(next).length === 0;
};
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
if (validate()) submitToAPI(form);
};
return (
<form onSubmit={handleSubmit} noValidate>
<Field
label="Email"
value={form.email}
error={errors.email}
onChange={(v) => setForm((f) => ({ ...f, email: v }))}
/>
<Field
label="Password"
type="password"
value={form.password}
error={errors.password}
onChange={(v) => setForm((f) => ({ ...f, password: v }))}
/>
<button type="submit">Log in</button>
</form>
);
}Accessible Field Component
function Field({ label, error, ...props }: FieldProps) {
const id = useId();
const errorId = `${id}-error`;
return (
<div>
<label htmlFor={id}>{label}</label>
<input
id={id}
aria-invalid={!!error}
aria-describedby={error ? errorId : undefined}
{...props}
/>
{error && (
<p id={errorId} role="alert">
{error}
</p>
)}
</div>
);
}Key accessibility details:
aria-invalidsignals the error state to assistive technologyaria-describedbylinks the error message to the inputrole="alert"announces the error when it appearsuseId()generates stable, unique IDs for SSR compatibility
Uncontrolled Forms and FormData
Not everything needs React state. For simple forms, FormData avoids re-renders entirely:
function SearchForm({ onSearch }: { onSearch: (q: string) => void }) {
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const data = new FormData(e.currentTarget);
onSearch(data.get('query') as string);
};
return (
<form onSubmit={handleSubmit}>
<input name="query" type="search" required />
<button type="submit">Search</button>
</form>
);
}React 19's form actions build on this pattern with server-side form handling.
Validation Strategy
Three levels of validation, each serving a different purpose:
- Client-side (instant) â UX feedback, not security. Use
onChangeoronBlurvalidation for immediate feedback. - Submission (pre-send) â validate the entire form before sending. Catch missing fields, format issues.
- Server-side (authoritative) â the only validation that matters for security. Never trust the client.
// Validate on blur for individual field feedback
<input onBlur={() => validateField('email', form.email)} />
// Validate on submit for form-level checks
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
const errors = validateForm(form);
if (Object.keys(errors).length) {
setErrors(errors);
focusFirstError(errors); // move focus to first invalid field
return;
}
submit(form);
};Focus Management on Errors
After validation fails, move focus to the first invalid field. This is critical for keyboard and screen reader users:
function focusFirstError(errors: Record<string, string>) {
const firstKey = Object.keys(errors)[0];
const el = document.getElementById(firstKey);
el?.focus();
}Interview Signal
Form questions test attention to detail. Senior signals:
- Accessibility-first â every input labeled, errors announced, focus managed
- Validation layering â client for UX, server for security
- Performance awareness â knowing when uncontrolled forms avoid unnecessary renders
- Progressive enhancement â forms that work without JavaScript (server actions, native validation)