Skip to content

React Tutorial Practical Projects

React Form Validation Project: A Sign-Up Form

Lesson 144 of 156 3 min read By Deepak Paswan Updated Oct 10, 2026

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

App.jsx
JSX
// 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

  1. validate is 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, so errors is derived, not stored (Basic Client-Side Validation).
  2. When to show an error. Two small pieces of state control timing: touched (fields the user has left) and submitted. 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.
  3. One change handler for all fields, using each input’s name, with a branch for the checkbox’s checked (Controlled Forms).
  4. Field is a small layout component. Each field gets its label, hint and error in a consistent place (Reusable Form Components).
  5. 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.
  6. Accessible errors. aria-invalid and aria-describedby link each message to its input. On a failed submit, focus moves to the first invalid field using the stored refs (Validation Messages).
  7. The strength meter is another derived value from password, shown in the hint text, and in words rather than colour only.
  8. Success state. When there are no errors, done becomes true and the form is replaced by a confirmation in a live region.
  9. Native niceties kept. autoComplete values help password managers, and type="email" gives mobile users the right keyboard. noValidate only 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

  1. Add a show password toggle (a button that switches type between password and text).
  2. Check username availability with a debounced request (use a fake API).
  3. Persist a draft (without the password) with the useLocalStorage hook.
  4. Convert the form to a multi-step wizard (account, profile, review).