Forms and Validation
Before you start
You need: hooks (Article 03).
Time: about 45 minutes, plus the practice.
Learning objective
Build a validated form that is accessible, cannot be double-submitted, and maps server errors back to individual fields.
Topics
- Controlled inputs
- One state object for a form
- Validation on blur and on submit
- Accessible error messages
- Select, radio and checkbox
- Submitting
- Server-side errors
- A reusable form hook
- The security boundary
Controlled inputs
const [name, setName] = useState('');
<input
id="name"
type="text"
value={name}
onChange={event => setName(event.target.value)}
/>
State is the source of truth: React renders the value, and every keystroke updates state.
Initialise to '', never undefined. An input with value={undefined} is uncontrolled, and setting a value later produces:
A component is changing an uncontrolled input to be controlled.
That warning appears constantly when loading a record into a form — the fix is always the initial value.
value without onChange makes the field read-only, and React warns about that too.
One state object
Fifteen useState calls for a fifteen-field form is unmanageable.
interface StudentInput {
name: string;
rollNumber: string;
className: string;
section: string;
dateOfBirth: string;
parentName: string;
parentPhone: string;
address: string;
}
const emptyStudent: StudentInput = {
name: '', rollNumber: '', className: '', section: '',
dateOfBirth: '', parentName: '', parentPhone: '', address: ''
};
export function StudentForm({ initial }: { initial?: StudentInput }) {
const [values, setValues] = useState<StudentInput>(initial ?? emptyStudent);
function handleChange(event: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) {
const { name, value } = event.target;
setValues(prev => ({ ...prev, [name]: value }));
}
return (
<input name="name" value={values.name} onChange={handleChange} />
);
}
The name attribute drives the update. One handler serves every field, and adding a field needs no new handler.
[name]: value is a computed key — the property named by the variable.
For a checkbox, read checked rather than value:
function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
const { name, value, type, checked } = event.target;
setValues(prev => ({ ...prev, [name]: type === 'checkbox' ? checked : value }));
}
Validation
type Errors = Partial<Record<keyof StudentInput, string>>;
function validate(values: StudentInput): Errors {
const errors: Errors = {};
if (!values.name.trim()) {
errors.name = 'Student name is required.';
} else if (values.name.length > 100) {
errors.name = 'Name cannot exceed 100 characters.';
}
if (!values.rollNumber.trim()) {
errors.rollNumber = 'Roll number is required.';
} else if (!/^NCA-\d{4}-\d{4}$/.test(values.rollNumber)) {
errors.rollNumber = 'Roll number must look like NCA-2024-0012.';
}
if (!values.className) {
errors.className = 'Class is required.';
}
if (!values.dateOfBirth) {
errors.dateOfBirth = 'Date of birth is required.';
} else {
const age = new Date().getFullYear() - new Date(values.dateOfBirth).getFullYear();
if (age < 3 || age > 25) {
errors.dateOfBirth = 'Date of birth gives an age outside the accepted range.';
}
}
if (!values.parentPhone.trim()) {
errors.parentPhone = 'Parent phone is required.';
} else if (!/^[6-9]\d{9}$/.test(values.parentPhone)) {
errors.parentPhone = 'Enter a valid 10-digit mobile number.';
}
return errors;
}
A plain function taking values and returning errors — testable with no component and no DOM:
expect(validate({ ...emptyStudent, rollNumber: '12345' }).rollNumber)
.toBe('Roll number must look like NCA-2024-0012.');
When to show them
const [touched, setTouched] = useState<Partial<Record<keyof StudentInput, boolean>>>({});
const [submitted, setSubmitted] = useState(false);
const errors = validate(values);
function handleBlur(event: React.FocusEvent<HTMLInputElement>) {
setTouched(prev => ({ ...prev, [event.target.name]: true }));
}
function showError(field: keyof StudentInput): string | undefined {
return (touched[field] || submitted) ? errors[field] : undefined;
}
Show an error only after the field is blurred or the form is submitted. Flagging a required field before the user has typed anything is hostile.
submitted covers the user who clicks Save without touching anything — otherwise the form refuses to submit and shows no reason.
errors is derived during render, so it is always current. Storing it as state means two sources of truth that will disagree.
Accessible errors
function Field({ label, name, error, children }: FieldProps) {
const errorId = `${name}-error`;
return (
<div className="field">
<label htmlFor={name}>{label}</label>
{children}
{error && (
<p id={errorId} className="error" role="alert">{error}</p>
)}
</div>
);
}
<Field label="Roll number" name="rollNumber" error={showError('rollNumber')}>
<input
id="rollNumber"
name="rollNumber"
type="text"
value={values.rollNumber}
onChange={handleChange}
onBlur={handleBlur}
placeholder="NCA-2024-0012"
aria-invalid={showError('rollNumber') ? true : undefined}
aria-describedby={showError('rollNumber') ? 'rollNumber-error' : undefined}
/>
</Field>
Three things make this accessible:
aria-invalidtells a screen reader the field is in error.aria-describedbylinks the message so it is read after the label.role="alert"announces it the moment it appears.
htmlFor, not for. A label without it is not associated with the input, so clicking it does nothing and a screen reader announces the field unnamed.
Colour alone is not enough. A red border is invisible to a colour-blind user; the text message conveys the problem.
Setting aria-invalid to undefined rather than false omits the attribute entirely, which is cleaner than aria-invalid="false" on every valid field.
Select, radio and checkbox
<select
id="className"
name="className"
value={values.className}
onChange={handleChange}
onBlur={handleBlur}
>
<option value="">-- Select class --</option>
{classOptions.map(option => (
<option key={option} value={option}>{option}</option>
))}
</select>
The empty first option is what makes the required check meaningful. Without it the first real option is preselected and the field can never be empty.
React uses value on the <select>, not selected on an option.
<fieldset>
<legend>Section</legend>
{['A', 'B', 'C'].map(section => (
<label key={section} htmlFor={`section-${section}`}>
<input
id={`section-${section}`}
type="radio"
name="section"
value={section}
checked={values.section === section}
onChange={handleChange}
/>
{section}
</label>
))}
</fieldset>
Radio buttons sharing one name is what makes them mutually exclusive. <fieldset> and <legend> group them so a screen reader announces "Section, A" rather than an unexplained "A".
<label htmlFor="isHosteller">
<input
id="isHosteller"
type="checkbox"
name="isHosteller"
checked={values.isHosteller}
onChange={handleChange}
/>
Hostel resident
</label>
Submitting
const [saving, setSaving] = useState(false);
const [serverError, setServerError] = useState<string | null>(null);
const [serverFieldErrors, setServerFieldErrors] = useState<Errors>({});
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setSubmitted(true);
setServerError(null);
setServerFieldErrors({});
if (Object.keys(errors).length > 0) {
focusFirstInvalid();
return;
}
setSaving(true);
try {
await studentApi.create(values);
navigate('/students?saved=1');
} catch (err) {
if (err instanceof ApiError && err.status === 400 && err.body?.errors) {
setServerFieldErrors(mapServerErrors(err.body.errors));
return;
}
setServerError(err instanceof ApiError && err.status === 409
? 'This roll number is already in use.'
: 'Could not save. Please try again.');
} finally {
setSaving(false);
}
}
<form onSubmit={handleSubmit} noValidate>
{serverError && <p className="error" role="alert">{serverError}</p>}
{/* fields */}
<button type="submit" disabled={saving}>
{saving ? 'Saving…' : 'Save'}
</button>
<button type="button" onClick={() => navigate('/students')}>
Cancel
</button>
</form>
Five details:
event.preventDefault() — without it the browser reloads and the SPA restarts.
noValidate disables the browser's own validation UI, so your messages are the only ones shown.
disabled={saving} prevents a double-click creating two students.
type="button" on Cancel. A <button> inside a form defaults to type="submit", so Cancel would submit.
finally clears saving on both success and failure. Clearing it only on success leaves the button disabled forever after an error.
Focusing the first invalid field takes a keyboard user to the problem:
function focusFirstInvalid() {
const first = document.querySelector<HTMLElement>('[aria-invalid="true"]');
first?.focus();
}
Server-side errors
ASP.NET Core returns a ValidationProblemDetails body on 400:
{
"status": 400,
"errors": {
"RollNumber": ["Format: NCA-2024-0012"],
"ParentPhone": ["Enter a valid 10-digit mobile number."]
}
}
function mapServerErrors(errors: Record<string, string[]>): Errors {
const mapped: Errors = {};
for (const [field, messages] of Object.entries(errors)) {
const key = (field.charAt(0).toLowerCase() + field.slice(1)) as keyof StudentInput;
if (messages[0]) {
mapped[key] = messages[0];
}
}
return mapped;
}
The PascalCase-to-camelCase conversion is what makes the keys match your state.
function showError(field: keyof StudentInput): string | undefined {
return serverFieldErrors[field] ?? ((touched[field] || submitted) ? errors[field] : undefined);
}
Server errors take precedence — they describe something the client could not know.
Field-level messages beat one generic banner. "Could not save" tells the user nothing about which field to fix.
A reusable form hook
export function useForm<T extends Record<string, unknown>>(
initial: T,
validateFn: (values: T) => Partial<Record<keyof T, string>>
) {
const [values, setValues] = useState<T>(initial);
const [touched, setTouched] = useState<Partial<Record<keyof T, boolean>>>({});
const [submitted, setSubmitted] = useState(false);
const [serverErrors, setServerErrors] = useState<Partial<Record<keyof T, string>>>({});
const errors = validateFn(values);
const isValid = Object.keys(errors).length === 0;
function handleChange(
event: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>
) {
const { name, value, type } = event.target;
const isCheckbox = type === 'checkbox';
const nextValue = isCheckbox ? (event.target as HTMLInputElement).checked : value;
setValues(prev => ({ ...prev, [name]: nextValue }));
setServerErrors(prev => ({ ...prev, [name]: undefined }));
}
function handleBlur(event: React.FocusEvent<HTMLElement>) {
const name = (event.target as HTMLInputElement).name;
setTouched(prev => ({ ...prev, [name]: true }));
}
function errorFor(field: keyof T): string | undefined {
return serverErrors[field] ?? ((touched[field] || submitted) ? errors[field] : undefined);
}
function reset(next?: T) {
setValues(next ?? initial);
setTouched({});
setSubmitted(false);
setServerErrors({});
}
return {
values, errors, isValid, submitted, serverErrors,
handleChange, handleBlur, errorFor, setSubmitted, setServerErrors, setValues, reset
};
}
export function StudentForm() {
const { values, isValid, handleChange, handleBlur, errorFor, setSubmitted, setServerErrors }
= useForm(emptyStudent, validate);
// the component is now markup plus a submit handler
}
Clearing the server error for a field on change matters: a stale "roll number taken" message beside a field the user has since corrected is worse than no message.
Form libraries
For anything larger, use a library rather than growing this hook:
| Library | Approach |
|---|---|
| React Hook Form | Uncontrolled inputs, minimal re-renders — the common choice |
| Formik | Controlled, mature, heavier |
| Zod | Schema validation, derives the TypeScript type |
React Hook Form plus Zod gives one schema that produces both the runtime validation and the type — a single source of truth. Worth adopting once a form exceeds about ten fields.
The security boundary
Client validation is a convenience, never a control.
Everything in this article is removable in DevTools, and the request can be sent without the page at all. Its only job is to save the user a round trip.
The server validates independently, and the database constraint is what wins a race between two users submitting the same roll number simultaneously. That is three layers, and each exists for a different reason.
A form whose validation exists only in React is a form with no validation.
Errors you will hit
| Message | Cause | Fix |
|---|---|---|
You provided a value prop to a form field without an onChange handler | Controlled input with no handler | Add onChange, or use defaultValue |
A component is changing an uncontrolled input to be controlled | Initial value was undefined | Initialise to '' |
| Validation messages appear immediately | Not tracking touched state | Track it |
| The form submits while invalid | No check before submit | Validate first |
| The API rejects a valid-looking form | Client validation is not the control | The server validates too |
Initialise every controlled input to '', never undefined. Otherwise React switches the input from uncontrolled to controlled and warns.
Common mistakes
- Initial state
undefined, producing the controlled/uncontrolled warning valuewithoutonChangeforinstead ofhtmlFor- Errors shown before blur or submit
- Errors stored as state instead of derived
- No
event.preventDefault()on submit - Cancel without
type="button" - No
disabledon submit, allowing a double-click savingcleared only on success- No empty first
<option>, so the required check does nothing selectedon an option instead ofvalueon the select- Reading
valueinstead ofcheckedfor a checkbox - No
aria-invalid,aria-describedbyorrole="alert" - Colour as the only error signal
- A generic banner instead of field-level server errors
- A stale server error left beside a corrected field
- Trusting client validation as a control
Practice
The course exercise is create a validated form.
- Build the student form with one state object and one
handleChange. - Initialise a field to
undefinedand set it later. Record the controlled/uncontrolled warning. - Write
valuewith noonChangeand try to type. Record the warning. - Show errors without checking
touched. Load the page and observe every field red. - Add
touched || submittedand confirm the improvement. - Click Save on an untouched invalid form without
submitted. Confirm nothing appears, then add it. - Add
aria-invalid,aria-describedbyandrole="alert". Test with a screen reader. - Use
forinstead ofhtmlForand confirm clicking the label does nothing. - Remove
event.preventDefault()and submit. Watch the page reload. - Remove
type="button"from Cancel and click it. - Remove
disabled={saving}and double-click Save. Confirm two students. - Clear
savingonly in the success path, force an error, and confirm the button stays disabled. - Remove the empty first
<option>and confirm the required check no longer fires. - Store
errorsas state instead of deriving it. Change a value without recomputing and confirm they disagree. - Map a 400
errorsobject to fields. Confirm each message appears beside the right input. - Leave a server error in place while the user corrects the field. Then clear it on change and compare.
- In DevTools, remove the disabled attribute and submit an invalid form. Confirm the server still rejects it.
- Extract everything into
useFormand reduce the component to markup.
Exercises 11, 12 and 17 correspond to a duplicate record, a stuck button, and a false sense of validation.
You can now
- Build an accessible validated form
- Keep inputs controlled from the first render
- Show errors only after a field is touched
- Map server validation errors to fields
- Say why the server validates again
Review questions
- What causes the controlled/uncontrolled warning, and what fixes it?
- Why derive
errorsduring render rather than storing them? - Why must
savingbe cleared infinally? - Why is client-side validation never a security control?
Next: Routing