Intermediate · Building the frontend · topic 14 of 34 · 30 min
Forms and client-side validation
Explanation
Controlled inputs
A controlled input is an input whose value comes from React state, and whose onChange handler updates that state on every key press. React is then the single source of truth: the value you validate and send is exactly what the user sees.
import { useState } from 'react';
function NoteForm({ onSave }) {
const [title, setTitle] = useState('');
const [body, setBody] = useState('');
const [errors, setErrors] = useState({});
const [saving, setSaving] = useState(false);
async function handleSubmit(event) {
event.preventDefault(); // stop the browser's full page reload
const found = validateNoteForm({ title, body });
setErrors(found);
if (Object.keys(found).length > 0) return;
setSaving(true);
try {
await onSave({ title: title.trim(), body });
} finally {
setSaving(false);
}
}
return (
<form onSubmit={handleSubmit} noValidate>
<label htmlFor="title">Title</label>
<input id="title" value={title} onChange={(e) => setTitle(e.target.value)}
required maxLength={100}
aria-invalid={errors.title ? true : undefined}
aria-describedby={errors.title ? 'title-error' : undefined} />
{errors.title && <p id="title-error" className="error">{errors.title}</p>}
<label htmlFor="body">Note</label>
<textarea id="body" value={body} onChange={(e) => setBody(e.target.value)} maxLength={5000} />
<button type="submit" disabled={saving}>{saving ? 'Saving...' : 'Save'}</button>
</form>
);
}onSubmit runs when the user presses Save or hits Enter in the title. A normal HTML form would send the data and reload the whole page. event.preventDefault() stops that, so your JavaScript can validate and send the data with fetch instead (you will connect it to the API in the next module).
Mirror the server rules
Client-side validation means checking the input in the browser before sending it. Use exactly the same rules as the backend, so the user hears about a problem at once instead of after a network round trip.
| Field | Rule (same as the server) | Message shown |
|---|---|---|
| title | Required, 1 to 100 characters after trimming spaces | Title is required / Title must be 100 characters or less |
| body | Optional, up to 5000 characters | Note must be 5000 characters or less |
| pinned | true or false (a checkbox) | No message needed |
Show errors as text, linked to the field
Put each error message right next to its field, in words. Mark the field with aria-invalid="true" (tells screen readers the value is wrong) and point to the message with aria-describedby="title-error" (the screen reader reads the message when the field gets focus). A red border is fine as extra decoration, but never the only sign.
<label for="title">Title</label>
<input id="title" name="title" required maxlength="100"
aria-invalid="true" aria-describedby="title-error">
<p id="title-error">Title is required</p>Stop double submits
On a slow network, users press Save twice. Without protection you get two identical notes. Keep a saving flag in state, set it to true when the request starts and back to false when it ends (finally runs on success and on error), and disable the button while it is true. Changing the text to "Saving..." tells the user something is happening.
React 19 form actions (good to know)
React 19 also lets you pass a function as the form's action: <form action={saveNote}>. React calls it with the form data when the form is submitted. The useActionState hook gives you the last result (for example the errors) and an isPending flag you can use to disable the button, and useFormStatus lets a child button read whether its form is pending. The ideas above (same rules, text errors, no double submit) stay the same whichever style you use.
Watch the lesson
Forms and client-side validation · 1/7
Next we build the note form. A form collects input, checks it and sends it.
Prefer a video by a teacher? Find free videos on “react 19 forms controlled inputs validation tutorial” on YouTube ↗
In real life: A train ticket booking form
On a train booking site, the passenger form checks fields as you fill them: the name cannot be empty, the age must be a number, and the mobile number must have 10 digits. Each problem appears in red text under its field, so you fix it before pressing Book. The Book button greys out and shows "Processing" after one press, so a double tap does not book two tickets. Even so, the booking server checks every field again, because a request can be sent without using the form at all.
Practice (0/3 done)
Write validateNoteForm(values) for the note form. values is { title, body } (either may be missing). Return an errors object, empty {} when everything is fine. Rules: if the title after trim() is empty, errors.title = "Title is required"; if it is longer than 100 characters after trim(), errors.title = "Title must be 100 characters or less". If body is longer than 5000 characters, errors.body = "Note must be 5000 characters or less". Add title before body.
Write the HTML of the note form in its error state. Inside a <form>: a <label for="title"> and an <input id="title" name="title"> with required and maxlength="100"; the input also has aria-invalid="true" and aria-describedby="title-error"; a <p id="title-error"> with the text "Title is required"; a <label for="body"> and a <textarea id="body" name="body" maxlength="5000">; and a <button type="submit"> with the text "Save".
Stop double submits in code. Write makeSaveOnce(save), where save(values) is an async function. It returns a new async function submit(values): if a save is already running, it returns the string "ignored" at once; otherwise it calls save(values) and returns its result. When save finishes (or throws), submit must accept calls again. Hint: keep a saving flag and use try/finally.
Quick quiz
Read more (free)
Pass the quiz (60% or more) or mark this topic as done when you are ready.
Your progress is saved on this device only.