A form that only collects text is easy. A form people can use is harder: it needs to tell them what is wrong, where, and how to fix it, and it must not lose their input. This lesson builds a small sign-up form with validation, accessible error messages and submit handling.
Submitting a form
Put the handler on the <form>'s onSubmit, not on the button's onClick. That way pressing Enter in a field submits the form too. The handler must call event.preventDefault() or the browser will reload the page.
import { useState } from "react";
function App() {
const [email, setEmail] = useState("");
const [message, setMessage] = useState("");
function handleSubmit(event) {
event.preventDefault();
setMessage("Subscribed: " + email);
}
return (
<form onSubmit={handleSubmit}>
<input name="email" type="email" value={email} onChange={e => setEmail(e.target.value)} placeholder="Email" />
<button type="submit">Subscribe</button>
<p>{message}</p>
</form>
);
}
export default App;Subscribe
Subscribed: asha@example.comA button inside a form is a submit button by default, so be explicit with type="button" for buttons that should not submit (like "Cancel").
Reading the values with FormData
If you do not need to track every keystroke, you can read all the fields when the form is submitted, using the browser's FormData. The inputs are then uncontrolled (they keep their own text):
function App() {
function handleSubmit(event) {
event.preventDefault();
const data = Object.fromEntries(new FormData(event.target));
console.log(JSON.stringify(data));
}
return (
<form onSubmit={handleSubmit}>
<input name="title" placeholder="Title" />
<input name="author" placeholder="Author" />
<button>Save</button>
</form>
);
}
export default App;Save
CONSOLE
{"title":"Hello","author":"Asha"}Every field must have a name to appear in the data. This is a simple, short way to handle a form. When you need live validation, character counters or fields that depend on each other, use controlled inputs as in the previous lesson. In React 19 there is a third way using form actions, shown in a later lesson.
Validation: calculate errors from the values
The values are in state, so the errors can be calculated from them while rendering (remember: do not store what you can derive). Write a pure function that returns an object of error messages:
import { useState } from "react";
function validate({ name, email, password }) {
const errors = {};
if (name.trim().length < 2) errors.name = "Enter your name (at least 2 characters).";
if (!/^\S+@\S+\.\S+$/.test(email)) errors.email = "Enter a valid email like name@example.com.";
if (password.length < 8) errors.password = "Password must be at least 8 characters.";
return errors;
}
function App() {
const [values, setValues] = useState({ name: "", email: "", password: "" });
const [submitted, setSubmitted] = useState(false);
const errors = validate(values); // derived on every render
const isValid = Object.keys(errors).length === 0;
function handleChange(event) {
setValues({ ...values, [event.target.name]: event.target.value });
}
function handleSubmit(event) {
event.preventDefault();
setSubmitted(true); // now errors become visible
if (isValid) console.log("Sending", values.email);
}
return (
<form onSubmit={handleSubmit} noValidate>
{["name", "email", "password"].map(field => (
<p key={field}>
<input
name={field}
type={field === "password" ? "password" : field === "email" ? "email" : "text"}
value={values[field]}
onChange={handleChange}
placeholder={field}
aria-invalid={submitted && errors[field] ? "true" : undefined}
aria-describedby={submitted && errors[field] ? field + "-error" : undefined}
/>
{submitted && errors[field] && (
<span id={field + "-error"} role="alert" style={{ color: "crimson", marginLeft: 8 }}>
{errors[field]}
</span>
)}
</p>
))}
<button type="submit">Create account</button>
</form>
);
}
export default App;Enter your name (at least 2 characters).
Enter a valid email like name@example.com.
Password must be at least 8 characters.
Create accountThe test typed short, invalid values and submitted. Look at the design choices:
validateis a pure function that takes the values and returns a map of messages. It is easy to test on its own, and the same rules can be reused.errorsis computed on every render, not stored. It can never be out of date, andisValidfollows from it.- Errors appear only after the first submit attempt (
submitted). Showing "Enter a valid email" while someone is still typing their first letter is rude. A common refinement is to track which fields the user has left (onBlur) and show errors for those, called "touched" state. noValidateon the form turns off the browser's own popups, so that you control the messages. Without it, the browser may stop the submit before your handler and show its own bubble.- Accessibility:
aria-invalid="true"marks the field,aria-describedbypoints to the id of the message so screen readers read it with the field, androle="alert"makes the message announce itself when it appears. Never rely on colour alone to show an error.
Built-in HTML validation
Browsers can validate for you with attributes: required, type="email", minLength, maxLength, min, max, pattern. They cost nothing and work with React unchanged:
function App() {
function handleSubmit(event) {
event.preventDefault();
console.log("valid, submitting");
}
return (
<form onSubmit={handleSubmit}>
<input name="email" type="email" required placeholder="Email" />
<input name="age" type="number" min="18" max="99" required placeholder="Age" />
<button>Join</button>
</form>
);
}
export default App;JoinSubmitting the empty form shows nothing in the console: the browser stopped it and showed its own "Please fill out this field" bubble (in the preview you would see it), so handleSubmit never ran. That is the main difference from the previous example. The built-in messages are plain and differ between browsers, and the styling is limited; many teams use them as a first layer and add their own rules for friendly messages, or use noValidate and do everything in JavaScript. Libraries such as React Hook Form or Zod help with big forms, but they build on these ideas.
Submit status
When the form sends data, the user needs feedback and protection from double clicks. Track a status in a single state value:
import { useState } from "react";
function fakeSend(email) {
return new Promise(resolve => setTimeout(resolve, 500)); // pretend to call a server
}
function App() {
const [email, setEmail] = useState("a@example.com");
const [status, setStatus] = useState("idle"); // idle | sending | done | error
async function handleSubmit(event) {
event.preventDefault();
setStatus("sending");
try {
await fakeSend(email);
setStatus("done");
} catch {
setStatus("error");
}
}
return (
<form onSubmit={handleSubmit}>
<input type="email" value={email} onChange={e => setEmail(e.target.value)} disabled={status === "sending"} />
<button disabled={status === "sending"}>{status === "sending" ? "Sending..." : "Subscribe"}</button>
{status === "done" && <p role="status">Thank you!</p>}
{status === "error" && <p role="alert">Something went wrong. Try again.</p>}
</form>
);
}
export default App;Subscribe
Thank you!The button and field are disabled while sending, so the user cannot submit twice, and a message confirms success. role="status" makes polite announcements for screen readers. In React 19, the useFormStatus and useActionState hooks make this pattern shorter, as the lesson on actions explains.
Server-side validation is not optional
Everything above runs in the visitor's browser, where they can change it. Someone can turn off JavaScript, edit the page, or send requests directly. Always validate again on the server before using or storing the data, and be ready to show server errors (for example "that email is already registered") in the same places as client errors. Client-side validation is for the user's convenience; server-side validation is for your security.
Common mistakes
- Putting the handler on the button's
onClickinstead of the form'sonSubmit. - Forgetting
event.preventDefault(), so the page reloads. - Showing errors before the user has finished their first attempt.
- Storing
errorsin state and forgetting to update it. - Giving feedback by colour alone, without text or ARIA attributes.
- Relying only on client-side checks.
- Leaving the submit button enabled while a request is in flight.
Exercise
- Add a "confirm password" field and an error when it differs from the password.
- Show each field's error as soon as the user leaves the field (
onBlur), tracking atouchedobject. - Disable the submit button while the form is invalid, and compare how that feels with showing errors on submit.
- Add a server-like check to
fakeSendthat rejects the e-mailtaken@example.com, and show the error under the field.
Key points
- Handle onSubmit on the form element, call event.preventDefault(), then validate and send the data.
- Calculate error messages from the form values instead of storing them, and show them next to the fields they belong to.
- Link errors to inputs with aria-describedby and aria-invalid, and use role=alert so screen readers announce them.
- Always validate again on the server: browser validation helps users but anyone can bypass it.