Validating Remix forms with Constraints API

The Constraints API is a browser API that has been available since the times of IE10, yep, that old. This API allows the browser to validate forms when using attributes like required or marking an input type as email.

And we can use it to validate our forms so we can run any custom validation and tell the browser input is invalid and the error message.

Then the input will receive the invalid state, so you can use :invalid in CSS to style it, and when the user submits the form, the browser will prevent it, and instead, it will show a native UI with the error message with defined.

Remix has this super simple Form component that works as a drop-in replacement of the <form> tag.

It will automatically submit our forms to the action we defined (the current route action by default). It will follow any redirect of the action function in the server return.

That component is fantastic, one of the best parts of Remix without any doubt, and it makes the client-server integration really simple.

That you could quickly validate the form server-side (which you should always do!), but it does nothing to help you validate the client-side before submitting the form.

So you have three options here:

  1. Don't validate client-side. Submit the form and get errors back from the server.
  2. Use a third-party library to validate the form client-side like Formik or React Hooks Form.
  3. Use the Constraints API to validate the form client-side and don't install any extra library!

We will see how to do the last one.

The Form

We will validate a super simple form with a single input and a submit button. This form will have an input element asking for a name, and we will ensure that if the user types blank spaces, it will be invalid.

<Form className="max-w-prose mx-auto my-40 shadow-md space-y-4 bg-white rounded-lg p-4">
  <div>
    <label htmlFor="name" className="block text-sm font-medium text-gray-700">
      Name
    </label>
    <div className="mt-1">
      <input
        type="text"
        name="name"
        id="name"
        className="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md invalid:border-red-500 valid:border-green-500"
        aria-describedby="email-error"
      />
    </div>
    {/* This is the error message that will be shown if the input is invalid */}
    {error && (
      <p className="mt-2 text-sm text-red-600" id="email-error">
        {error}
      </p>
    )}
  </div>

  <button
    type="submit"
    className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
  >
    Submit
  </button>
</Form>

Adding a validation

One of the best places to validate input is the blur event, so we can let the user type calmly, and then when the user leaves the input, we can validate it. This way, we will not show any error message until the user finishes with that input.

So lets' add an onBlur event handler.

function handleBlur(event) {
  // get the input element
  let $input = event.currentTarget;
  // get the value
  let value = $input.value;
}

// Inside our form
<input
  type="text"
  name="name"
  id="name"
  className="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md invalid:border-red-500 valid:border-green-500"
  aria-describedby="email-error"
  onBlur={handleBlur}
/>;

Now, we need to do our validation, we are going to do this:

let isValid = value.trim().length > 0;

Once we know if it's valid or not, we can call the setCustomValidity method on the input element to set the error message.

$input.setCustomValidity(isValid ? "" : "The name can't be empty.");

If we pass an empty string to setCustomValidity, we tell the browser the input is valid. Any other value will be set as the error message and mark the input as invalid.

Finally, we can check the validity of the input by calling the checkValidity method on the input element. This method returns a boolean. Here we can save the validationMessage inside a React state to show it in the UI.

if ($input.checkValidity()) setError("");
else setError($input.validationMessage);

The complete code

Here's the complete code of our route component.

export default function Screen() {
  // we have this state to show the error in the UI
  let [error, setError] = useState("");

  function validate(event: FocusEvent<HTMLInputElement>) {
    let $input = event.currentTarget; // get the input
    let value = $input.value; // get the value
    let isValid = value.trim().length > 0; // validate it
    // set the error message if it's invalid or set `""` if it's valid
    $input.setCustomValidity(isValid ? "" : "It can't be empty.");
    // check if it's valid or not to update the state
    if ($input.checkValidity()) setError("");
    else setError($input.validationMessage);
  }

  // And our components
  return (
    <Form className="max-w-prose mx-auto my-40 shadow-md space-y-4 bg-white rounded-lg p-4">
      <div>
        <label
          htmlFor="name"
          className="block text-sm font-medium text-gray-700"
        >
          Name
        </label>
        <div className="mt-1">
          <input
            onBlur={validate}
            type="text"
            name="name"
            id="name"
            className="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md invalid:border-red-500 valid:border-green-500"
            aria-invalid={Boolean(error)}
            aria-describedby="email-error"
          />
        </div>
        {error && (
          <p className="mt-2 text-sm text-red-600" id="email-error">
            {error}
          </p>
        )}
      </div>

      <button
        type="submit"
        className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
      >
        Submit
      </button>
    </Form>
  );
}

Submitting the form

Now, when the user leaves the input, we will validate it and show the error message. If the user ignores the error and submits the form, the browser will prevent it and show the native UI for error messages.

Because the browser prevents submitting the form, we don't need to attach any onSubmit event handler and let Remix keep doing that for us.

The no JS scenario

Suppose we don't have JavaScript enabled (it failed, or we decided not to use it). In that case, the user will be able to submit the form. Any native validation will still work (like required or type="email"). Once the form reaches our action, we can validate it there.

So it's crucial to keep validating server-side! The user could also find ways to bypass the validation and submit the form, even with JS.