Lightweight Forms Validation in React

How to use native HTML forms validation in React and customize the validation messages.

When you encounter form validation in React, you don't need to immediately reach out for some forms library. Try the native forms validation with validation constraints API – you can customize the look of validation messages and their contents. Play with the final result and check out the example repository.

Form with native validation

You have a simple form on your website. Perhaps it's a login form or a newsletter sign-up – a few fields and a submit button. You don't have any complex interaction logic, so just grab form contents with FormData in onSubmit handler and send them to the backend.

Let's create a form with a single e-mail field. Including HTML attributes for client-side validation.

function onSubmit(e) {
  e.preventDefault();
  const formData = new FormData(e.currentTarget);
  // Do something with the data
  console.log(Object.fromEntries(formData.entries()));
}

function Form() {
  return (
    <form onSubmit={onSubmit}>
      <p>
        <label>
          Your e-mail
          <input type="email" name="email" required />
        </label>
      </p>
      <p>
        <button type="submit">Submit</button>
      </p>
    </form>
  );
}

Now, when I try to submit an empty or invalid form, the browser gives me a nice pop-up message with default styling.

Screenshot of a form with an e-mail field and browser error message 'Please fill out this field.'

HTML validation message in Chrome (try it in the sandbox).

Rendering the validation message

Maybe you don't like how the browser's pop-up looks. Perhaps you want it to look the same in all browsers, or you want to put the validation error somewhere else. At this point, you may be considering to do the validation logic yourself or to reach out for some React forms library.

But the Constraint validation API provides a good abstraction to start with. Nowadays is also well-supported in browsers.

When a field fails the validation, it triggers an invalid event. The error message can be read from the input field's validationMessage property.

function Input() {
  // Passed into input's onInvalid prop
  const invalidHandler = (e) => {
    // e.target is the input
    const validationMessage = e.target.validationMessage;
    // prints: 'Please fill out this field.'
    console.log(validationMessage);
  };

  return (
    <input onInvalid={invalidHandler} type="email" name="email" required />
  );
}

Now that we have access to this message, we can show it to the user. For example, we can store it in a local state and render it. But we also need to prevent the browser from showing the pop-up message – this is done with e.preventDefault().

function Input(props) {
  const [validationMessage, setValidationMessage] = useState();
  const invalidHandler = (e) => {
    const validationMessage = e.target.validationMessage;
    setValidationMessage(validationMessage);
    e.preventDefault();
  };
  return (
    <>
      <input onInvalid={invalidHandler} type="email" name="email" required />
      <span className="validation-message">{validationMessage}</span>
    </>
  );
}
Screenshot of a form with an e-mail field and a red error message 'Please enter an email address.'

Validation message with custom rendering (try it in the sandbox).

Resetting the state

There's one issue with this solution: the validation error is being shown even after the field is fixed.

Screenshot of a form with a correct email address in a field, still displaying an error message 'Please enter an email address.'

When you enter a correct e-mail address, the error message is still shown (try it in the sandbox).

This is because the invalid handler is triggered only with the form submission. We can listen for additional field events, like blur or change, to update or hide the validation message.

function Input(props) {
  const [validationMessage, setValidationMessage] = useState();
  const invalidHandler = (e) => {
    const validationMessage = e.target.validationMessage;
    setValidationMessage(validationMessage);
    e.preventDefault();
  };
  return (
    <>
      <input
        onInvalid={invalidHandler}
        onChange={invalidHandler}
        type="email"
        name="email"
        required
      />
      <span className="validation-message">{validationMessage}</span>
    </>
  );
}

This is a bare minimum to use native HTML forms validation with React. You can play with the result in a sandbox and here's a repository.

Advantages and disadvantages

If you have only a small amount of simple forms, native HTML validation can get you quickly to usable results.

The biggest advantages of this approach are fewer dependencies and progressive enhancement – the validation will work even if the client fails to load a JavaScript. Probably not a big concern with React, but totally viable if you are using server-side rendering (for example, with frameworks like Next.js or Remix). Only the backend must be able to accept a form submitted without JavaScript.

On the other hand, there are some disadvantages.

For starters, the default validation messages respect the browser's locale, not of the page. The result may be a bit confusing. For example, if you use a custom input pattern with explanation, you can end up with mixed languages in validation message (although it seems like the title is displayed only by Firefox).

Screenshot of a form with a an email missing a top level domain and validation message about incorrect format in two different languages.'

An e-mail field with a custom pattern and a title, displayed in Firefox with Czech locale. The result is a validation message in mixed languages (try it in the sandbox).

Different browsers provide different validation messages, so if you need a tight control over copy, you must provide the messages yourself. You can read field's validity property, which gives you the validity state. The article about the constraints validation API from CSS Tricks includes a convenient hasError function to get you started. On the other hand, most libraries give you the same validation messages across all browsers.

Since constraints validation API is tied to HTML, it is harder to share validation logic with the backend. For example, the Formik forms library uses Yup library for data validation. You can use the same validation schema both on the client, and the server.

For a forms-heavy application I wouldn't hesitate to pick some popular library, like React Hook Form, React Final Form, or Formik. But for a simple website with a single “Contact Us” form? I'd try to keep things light.

Resources