Lightweight Form Validation with Alpine.js and Iodine.js

Avatar of Hugh Haworth
Hugh Haworth on (Updated on )

Many users these days expect instant feedback in form validation. How do you achieve this level of interactivity when you’re building a small static site or a server-rendered Rails or Laravel app? Alpine.js and Iodine.js are two minimal JavaScript libraries we can use to create highly interactive forms with little technical debt and a negligible hit to our page-load time. Libraries like these prevent you from having to pull in build-step heavy JavaScript tooling which can complicate your architecture.

I‘m going to iterate through a few versions of form validation to explain the APIs of these two libraries. If you want to copy and paste the finished product here‘s what we’re going to build. Try playing around with missing or invalid inputs and see how the form reacts:

A quick look at the libraries

Before we really dig in, it’s a good idea to get acquainted with the tooling we’re using.

Alpine is designed to be pulled into your project from a CDN. No build step, no bundler config, and no dependencies. It only needs a short GitHub README for its documentation. At only 8.36 kilobytes minfied and gzipped, it’s about a fifth of the size of a create-react-app hello world. Hugo Di Fracesco offers a complete and thorough overview of what it is an how it works. His initial description of it is pretty great:

Alpine.js is a Vue template-flavored replacement for jQuery and vanilla JavaScript rather than a React/Vue/Svelte/WhateverFramework competitor.

Iodine, on the other hand, is a micro form validation library, created by Matt Kingshott who works in the Laravel/Vue/Tailwind world. Iodine can be used with any front-end-framework as a form validation helper. It allows us to validate a single piece of data with multiple rules. Iodine also returns sensible error messages when validation fails. You can read more in Matt’s blog post explaining the reasoning behind Iodine.

A quick look at how Iodine works

Here’s a very basic client side form validation using Iodine. We‘ll write some vanilla JavaScript to listen for when the form is submitted, then use DOM methods to map through the inputs to check each of the input values. If it‘s incorrect, we’ll add an “invalid” class to the invalid inputs and prevent the form from submitting.

We’ll pull in Iodine from this CDN link for this example:

<script src="https://cdn.jsdelivr.net/gh/mattkingshott/iodine@3/dist/iodine.min.js" defer></script>

Or we can import it into a project with Skypack:

import kingshottIodine from "https://cdn.skypack.dev/@kingshott/iodine";

We need to import kingshottIodine when importing Iodine from Skypack. This still adds Iodine to our global/window scope. In your user code, you can continue to refer to the library as Iodine, but make sure to import kingshottIodine if you’re grabbing it from Skypack.

To check each input, we call the is method on Iodine. We pass the value of the input as the first parameter, and an array of strings as the second parameter. These strings are the rules the input needs to follow to be valid. A list of built-in rules can be found in the Iodine documentation.

Iodine’s is method either returns true if the value is valid, or a string that indicates the failed rule if the check fails. This means we‘ll need to use a strict comparison when reacting to the output of the function; otherwise, JavaScript assesses the string as true. What we can do is store an array of strings for the rules for each input as JSON in HTML data attributes. This isn’t built into either Alpine or Iodine, but I find it a nice way to co-locate inputs with their constraints. Note that if you do this you’ll need to surround the JSON with single quotes and use double quotes inside the attribute to follow the JSON spec.

Here’s how this looks in our HTML:

<input name="email" type="email" id="email" data-rules='["required","email"]'>

When we‘re mapping through the DOM to check the validity of each input, we call the Iodine function with the element‘s input value, then the JSON.encode() result of the input’s dataset.rules. This is what this looks like using vanilla JavaScript DOM methods:

let form = document.getElementById("form");

// This is a nice way of getting a list of checkable input elements
// And converting them into an array so we can use map/filter/reduce functions:
let inputs = [...form.querySelectorAll("input[data-rules]")];

function onSubmit(event) {
  inputs.map((input) => {
    if (Iodine.is(input.value, JSON.parse(input.dataset.rules)) !== true) {
      event.preventDefault();
      input.classList.add("invalid");
    }
  });
}
form.addEventListener("submit", onSubmit);

Here’s what this very basic implementation looks like:

As you can tell this is not a great user experience. Most importantly, we aren’t telling the user what is wrong with the submission. The user also has to wait until the form is submitted before finding out anything is wrong. And frustratingly, all of the inputs keep the “invalid” class even after the user has corrected them to follow our validation rules.

This is where Alpine comes into play

Let’s pull it in and use it to provide nice user feedback while interacting with the form.

A good option for form validation is to validate an input when it’s blurred or on any changes after it has been blurred. This makes sure we‘re not yelling at the user before they’ve finished writing, but still give them instant feedback if they leave an invalid input or go back and correct an input value.

We’ll pull Alpine in from the CDN:

<script src="https://cdn.jsdelivr.net/gh/alpinejs/[email protected]/dist/alpine.min.js" defer></script>

Or we can import it into a project with Skypack:

import alpinejs from "https://cdn.skypack.dev/alpinejs";

Now there’s only two pieces of state we need to hold for each input:

  • Whether the input has been blurred
  • The error message (the absence of this will mean we have a valid input)

The validation that we show in the form is going to be a function of these two pieces of state.

Alpine lets us hold this state in a component by declaring a plain JavaScript object in an x-data attribute on a parent element. This state can be accessed and mutated by its children elements to create interactivity. To keep our HTML clean, we can declare a JavaScript function that returns all the data and/or functions the form would need. Then we just need to register our function with Alpine using the Alpine.data attribute, and call Alpine.start() after registering it. Using Alpine this way also provides a reusable way to share logic as we can use the same function in multiple components or even multiple projects.

Let’s initialize the form data to hold objects for each input field with two properties: an empty string for the errorMessage and a boolean called blurred. We’ll use the name attribute of each element as their keys.


<form id="form" x-data="form" action="">
  <h1>Log In</h1>

  <label for="username">Username</label>
  <input name="username" id="username" type="text" data-rules='["required"]'>

  <label for="email">Email</label>
  <input name="email" type="email" id="email" data-rules='["required","email"]'>

  <label for="password">Password</label>
  <input name="password" type="password" id="password" data-rules='["required","minimum:8"]'>

  <label for="passwordConf">Confirm Password</label>
  <input name="passwordConf" type="password" id="passwordConf" data-rules='["required","minimum:8"]'>

  <input type="submit">
</form>

And here’s our function to set up the data. Note that the keys match the name attribute of our inputs:


Alpine.data("form", form);
Alpine.start();
function form(){ 
  return {
    username: {errorMessage:'', blurred:false},
    email: {errorMessage:'', blurred:false},
    password: {errorMessage:'', blurred:false},
    passwordConf: {errorMessage:'', blurred:false},
  }
}

Now we can use Alpine’s x-bind:class attribute on our inputs to add the “invalid” class if the input has blurred and a message exists for the element in our component data. Here’s how this looks in our username input:

<input name="username" id="username" type="text" 
x-bind:class="{'invalid':username.errorMessage && username.blurred}" data-rules='["required"]'>

Responding to input changes

Now we need our form to respond to input changes and on blurring input states. We can do this by adding event listeners. Alpine gives a concise API to do this either using x-on or, similar to Vue, we can use an @ symbol. Both ways of declaring these act the same way.

On the input event we need to change the errorMessage in the component data to an error message if the value is invalid; otherwise, we’ll make it an empty string.

On the blur event we need to set the blurred property as true on the object with a key matching the name of the blurred element. We also need to recalculate the error message to make sure it doesn’t use the blank string we initialized as the error message.

So we’re going to add two more functions to our form to react to blurring and input changes, and use the name value of the event target to find what part of our component data to change. We can declare these functions as properties in the object returned by the form() function.

Here’s our HTML for the username input with the event listeners attached:

<input 
  name="username" id="username" type="text"
  x-bind:class="{'invalid':username.errorMessage && username.blurred}" 
  @blur="blur" @input="input"
  data-rules='["required"]'
>

And our JavaScript with the functions responding to the event listeners:

function form(){
  return {
    username: {errorMessage:'', blurred:false},
    email: {errorMessage:'', blurred:false},
    password:{ errorMessage:'', blurred:false},
    passwordConf: {errorMessage:'', blurred:false},
    blur: function(event) {
      let ele = event.target;
      this[ele.name].blurred = true;
      let rules = JSON.parse(ele.dataset.rules)
      this[ele.name].errorMessage = this.getErrorMessage(ele.value, rules);
    },
    input: function(event) {
      let ele = event.target;
      let rules = JSON.parse(ele.dataset.rules)
      this[ele.name].errorMessage = this.getErrorMessage(ele.value, rules);
    },
    getErrorMessage: function() {
    // to be completed
    }
  }
}

Getting and showing errors

Next up, we need to write our getErrorMessage function.

If the Iodine check returns true, we‘ll set the errorMessage property to an empty string. Otherwise, we’ll pass the rule that has broken to another Iodine method: getErrorMessage. This will return a human-readable message. Here’s what this looks like:

getErrorMessage:function(value, rules){
  let isValid = Iodine.is(value, rules);
  if (isValid !== true) {
    return Iodine.getErrorMessage(isValid);
  }
  return '';
}

Now we also need to show our error messages to the user.

Let’s add <p> tags with an error-message class below each input. We can use another Alpine attribute called x-show on these elements to only show them when their error message exists. The x-show attribute causes Alpine to toggle display: none; on the element based on whether a JavaScript expression resolves to true. We can use the same expression we used in the show-invalid class on the input.

To display the text, we can connect our error message with x-text. This will automatically bind the innertext to a JavaScript expression where we can use our component state. Here’s what this looks like:

<p x-show="username.errorMessage && username.blurred" x-text="username.errorMessage" class="error-message"></p>

One last thing we can do is re-use the onsubmit code from before we pulled in Alpine, but this time we can add the event listener to the form element with @submit and use a submit function in our component data. Alpine lets us use $el to refer to the parent element holding our component state. This means we don’t have to write lengthier DOM methods:

<form id="form" x-data="form" @submit="submit" action="">
  <!-- inputs...  -->
</form>
submit: function (event) {
  let inputs = [...this.$el.querySelectorAll("input[data-rules]")];
  inputs.map((input) => {
    if (Iodine.is(input.value, JSON.parse(input.dataset.rules)) !== true) {
      event.preventDefault();
    }
  });
}

This is getting there:

  • We have real-time feedback when the input is corrected.
  • Our form tells the user about any issues before they submit the form, and only after they’ve blurred the inputs.
  • Our form does not submit when there are invalid properties.

Validating on the client side of a server-side rendered app

There are still some problems with this version, though some won‘t be immediately obvious in the Pen as they‘re related to the server. For example, it‘s difficult to validate all errors on the client side in a server-side rendered app. What if the email address is already in use? Or a complicated database record needs to be checked? Our form needs to have a way to show errors found on the server. There are ways to do this with AJAX, but we’ll look at a more lightweight solution.

We can store the server side errors in another JSON array data attribute on each input. Most back-end frameworks will provide a reasonably easy way to do this. We can use another Alpine attribute called x-init to run a function when the component initializes. In this function we can pull the server-side errors from the DOM into each input’s component data. Then we can update the getErrorMessage function to check whether there are server errors and return these first. If none exist, then we can check for client-side errors.

<input name="username" id="username" type="text" 
x-bind:class="{'invalid':username.errorMessage && username.blurred}" 
@blur="blur" @input="input" data-rules='["required"]' 
data-server-errors='["Username already in use"]'>

And to make sure the server side errors don’t show the whole time, even after the user starts correcting them, we’ll replace them with an empty array whenever their input gets changed.

Here’s what our init function looks like now:

init: function () {
  this.inputElements = [...this.$el.querySelectorAll("input[data-rules]")];
  this.initDomData();
},
initDomData: function () {
  this.inputElements.map((ele) => {
  this[ele.name] = {
    serverErrors: JSON.parse(ele.dataset.serverErrors),
    blurred: false
    };
  });
}

Handling interdependent inputs

Some of the form inputs may depend on others for their validity. For example, a password confirmation input would depend on the password it is confirming. Or a date you started a job field would need to hold a value later than your date-of-birth field. This means it’s a good idea to check all the inputs of the form every time an input gets changed.

We can map through all of the input elements and set their state on every input and blur event. This way, we know that inputs that rely on each other will not be using stale data.

To test this out, let’s add a matchingPassword rule for our password confirmation. Iodine lets us add new custom rules with an addRule method.

Iodine.addRule(
  "matchingPassword",
  value => value === document.getElementById("password").value
);

Now we can set a custom error message by adding a key to the messages property in Iodine:

Iodine.messages.matchingPassword="Password confirmation needs to match password";

We can add both of these calls in our init function to set up this rule.

In our previous implementation, we could have changed the “password” field and it wouldn’t have made the “password confirmation” field invalid. But now that we’re mapping through all the inputs on every change, our form will always make sure the password and the password confirmation match.

Some finishing touches

One little refactor we can do is to make the getErrorMessage function only return a message if the input has been blurred — this can make our HTML slightly shorter by only needing to check one value before deciding whether to invalidate an input. This means our x-bind attribute can be as short as this:

x-bind:class="{'invalid':username.errorMessage}"

Here’s what our functions look like to map through the inputs and set the errorMessage data now:

updateErrorMessages: function () {
  // Map through the input elements and set the 'errorMessage'
  this.inputElements.map((ele) => {
    this[ele.name].errorMessage = this.getErrorMessage(ele);
  });
},
getErrorMessage: function (ele) {
  // Return any server errors if they're present
  if (this[ele.name].serverErrors.length > 0) {
    return this[ele.name].serverErrors[0];
  }
  // Check using Iodine and return the error message only if the element has not been blurred
  const error = Iodine.is(ele.value, JSON.parse(ele.dataset.rules));
  if (error !== true && this[ele.name].blurred) {
    return Iodine.getErrorMessage(error);
  }
  // Return empty string if there are no errors
  return "";
},

We can also remove the @blur and @input events from all of our inputs by listening for these events in the parent form element. However, there is a problem with this: the blur event does not bubble (parent elements listening for this event will not be passed it when it fires on their children). Luckily, we can replace blur with the focusout event, which is basically the same event, but this one bubbles, so we can listen for it in our form parent element.

Finally, our code is growing a lot of boilerplate. If we were to change any input names we would have to rewrite the data in our function every time and add new event listeners. To prevent rewriting the component data every time, we can map through the form’s inputs that have a data-rules attribute to generate our initial component data in the init function. This makes the code more reusable for additional forms. All we’d need to do is include the JavaScript and add the rules as a data attribute and we’re good to go.

Oh, and hey, just because it’s so easy to do with Alpine, let’s add a fade-in transition that brings attention to the error messaging:

<p class="error-message" x-show="username.errorMessage" x-text="username.errorMessage" x-transition:enter></p>

And here’s the end result. Reactive, reusable form validation at a minimal page-load cost.

If you want to use this in your own application, you can copy the form function to reuse all the logic we’ve written. All you’d need to do is configure your HTML attributes and you’d be ready to go.