Runnable lesson · press Try it on the examples to edit and run them here.
This project combines everything in the Forms chapter into one realistic sign-up form.
Requirements
- Fields: name, email, password, confirm password and a terms checkbox.
- Rules: name required; email must look valid; password at least 8 characters with a letter and a number; passwords must match; terms must be accepted.
- Show an error for a field after the user leaves it (blur), and for all fields when they press submit.
- Errors are accessible (connected to their fields, announced, focus moves to the first error).
- A password strength hint.
- On success, show a confirmation instead of the form.
The solution
// Rules: a pure function from values to an object of error messages
function validate(v) {
const errors = {};
if (!v.name.trim()) errors.name = "Enter your name.";
if (!/^\S+@\S+\.\S+$/.test(v.email)) errors.email = "Enter a valid email address.";
if (v.password.length < 8) errors.password = "Use at least 8 characters.";
else if (!/[A-Za-z]/.test(v.password) || !/\d/.test(v.password)) errors.password = "Include at least one letter and one number.";
if (v.confirm !== v.password) errors.confirm = "The passwords do not match.";
if (!v.terms) errors.terms = "You must accept the terms to continue.";
return errors;
}
function strength(password) {
let score = 0;
if (password.length >= 8) score++;
if (password.length >= 12) score++;
if (/[A-Z]/.test(password) && /[a-z]/.test(password)) score++;
if (/\d/.test(password)) score++;
if (/[^A-Za-z0-9]/.test(password)) score++;
return score <= 2 ? "Weak" : score <= 4 ? "Medium" : "Strong";
}
function Field({ id, label, error, hint, children }) {
return (
<div style={{ marginBottom: 12 }}>
<label htmlFor={id} style={{ display: "block", fontWeight: 600 }}>{label}</label>
{children}
{hint && <small id={id + "-hint"} style={{ display: "block", color: "#555" }}>{hint}</small>}
{error && <small id={id + "-error"} style={{ display: "block", color: "#b71c1c" }}>{error}</small>}
</div>
);
}
function App() {
const [values, setValues] = React.useState({ name: "", email: "", password: "", confirm: "", terms: false });
const [touched, setTouched] = React.useState({});
const [submitted, setSubmitted] = React.useState(false);
const [done, setDone] = React.useState(false);
const refs = React.useRef({});
const errors = validate(values); // derived on every render
const show = (field) => (touched[field] || submitted) && errors[field];
function handleChange(e) {
const { name, type, value, checked } = e.target;
setValues({ ...values, [name]: type === "checkbox" ? checked : value });
}
const handleBlur = (e) => setTouched({ ...touched, [e.target.name]: true });
function handleSubmit(e) {
e.preventDefault();
setSubmitted(true);
const firstInvalid = Object.keys(errors)[0];
if (firstInvalid) {
refs.current[firstInvalid]?.focus(); // send focus to the first problem
return;
}
setDone(true);
}
if (done) {
return <p role="status" style={{ fontFamily: "system-ui, sans-serif" }}>Welcome, {values.name}! Your account has been created.</p>;
}
// Props shared by every text input
const input = (name, extra = {}) => ({
id: name, name, value: values[name], onChange: handleChange, onBlur: handleBlur,
ref: (el) => { refs.current[name] = el; },
"aria-invalid": show(name) ? "true" : undefined,
"aria-describedby": show(name) ? name + "-error" : undefined,
style: { width: "100%", boxSizing: "border-box", padding: 6 },
...extra,
});
return (
<main style={{ fontFamily: "system-ui, sans-serif", maxWidth: 360 }}>
<h1>Create your account</h1>
<form onSubmit={handleSubmit} noValidate>
<Field id="name" label="Name" error={show("name")}>
<input {...input("name", { autoComplete: "name", "aria-label": "Name" })} />
</Field>
<Field id="email" label="Email" error={show("email")}>
<input {...input("email", { type: "email", autoComplete: "email", "aria-label": "Email" })} />
</Field>
<Field id="password" label="Password" error={show("password")} hint={values.password ? "Strength: " + strength(values.password) : "At least 8 characters, with a letter and a number"}>
<input {...input("password", { type: "password", autoComplete: "new-password", "aria-label": "Password" })} />
</Field>
<Field id="confirm" label="Confirm password" error={show("confirm")}>
<input {...input("confirm", { type: "password", autoComplete: "new-password", "aria-label": "Confirm password" })} />
</Field>
<div style={{ marginBottom: 12 }}>
<label>
<input type="checkbox" name="terms" checked={values.terms} onChange={handleChange} onBlur={handleBlur} ref={(el) => { refs.current.terms = el; }} aria-invalid={show("terms") ? "true" : undefined} />
{" "}I accept the terms
</label>
{show("terms") && <small style={{ display: "block", color: "#b71c1c" }}>{errors.terms}</small>}
</div>
<button type="submit">Create account</button>
</form>
</main>
);
}
Walkthrough
validateis a pure function outside the component: values in, an object of messages out. It is easy to test and could be reused on the server. It runs on every render, soerrorsis derived, not stored (Basic Client-Side Validation).- When to show an error. Two small pieces of state control timing:
touched(fields the user has left) andsubmitted.show(field)combines them, so errors appear after blur or after a submit attempt, but not while the user is still typing for the first time. - One change handler for all fields, using each input’s
name, with a branch for the checkbox’schecked(Controlled Forms). Fieldis a small layout component. Each field gets its label, hint and error in a consistent place (Reusable Form Components).- The
input()helper returns the props that every text input shares (value, handlers, ARIA attributes), and spreading it keeps the JSX short. It also stores a ref per field. - Accessible errors.
aria-invalidandaria-describedbylink each message to its input. On a failed submit, focus moves to the first invalid field using the stored refs (Validation Messages). - The strength meter is another derived value from
password, shown in the hint text, and in words rather than colour only. - Success state. When there are no errors,
donebecomes true and the form is replaced by a confirmation in a live region. - Native niceties kept.
autoCompletevalues help password managers, andtype="email"gives mobile users the right keyboard.noValidateonly disables the browser’s own bubbles so our messages are the ones shown.
Decisions worth noticing
- No form library here, to see what happens under the hood. For many fields and rules, React Hook Form with a schema validator (such as Zod) keeps the code shorter and shares rules with the server.
- The server must validate again. This form only helps honest users.
- Never log or store the password in the browser; the example does not send anything anywhere.
- Email validation here is intentionally simple: the only real test of an address is sending a message to it.
Make it yours
- Add a show password toggle (a button that switches
typebetweenpasswordandtext). - Check username availability with a debounced request (use a fake API).
- Persist a draft (without the password) with the
useLocalStoragehook. - Convert the form to a multi-step wizard (account, profile, review).