Himanshu SharmaSoftware engineer
Let’s talk

React · Lesson 20 of 38

Forms and validation

Submit React forms with onSubmit, validate input, show accessible error messages, read FormData, track submit status and always validate on the server too.

20 minFormsUpdated 24 October 2026

By the end of this lessonBuild a form that validates its input, shows clear and accessible errors, and handles submission without reloading the page.

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.

jsx
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;
result
Subscribe
Subscribed: asha@example.com

A 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):

jsx
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;
result
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:

jsx
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;
result
Enter your name (at least 2 characters).
Enter a valid email like name@example.com.
Password must be at least 8 characters.
Create account

The test typed short, invalid values and submitted. Look at the design choices:

  • validate is 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.
  • errors is computed on every render, not stored. It can never be out of date, and isValid follows 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.
  • noValidate on 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-describedby points to the id of the message so screen readers read it with the field, and role="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:

jsx
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;
result
Join

Submitting 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:

jsx
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;
result
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 onClick instead of the form's onSubmit.
  • Forgetting event.preventDefault(), so the page reloads.
  • Showing errors before the user has finished their first attempt.
  • Storing errors in 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

  1. Add a "confirm password" field and an error when it differs from the password.
  2. Show each field's error as soon as the user leaves the field (onBlur), tracking a touched object.
  3. Disable the submit button while the form is invalid, and compare how that feels with showing errors on submit.
  4. Add a server-like check to fakeSend that rejects the e-mail taken@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.

Check yourself

1. Where should you put the submit handler?
2. Why call event.preventDefault() in the submit handler?
3. Can client-side validation be trusted for security?
4. What does aria-invalid="true" on an input tell assistive technology?