cdruc

InertiaJS frontend validation using Intus

This post is about pairing the InertiaJS form helper with Intus - a framework-agnostic, client-side validation library I recently published.

As an example, we'll use a contact form that has a name, email, phone, and message field. A POST request is made to a /contact endpoint when submitting the form. If the request is successful, we reset the form values and display a success message by checking the form.wasSuccessful property.

To simplify the markup, the following snippet uses some imaginary components like BoxAlert, FormInput, etc.

<script setup>
const form = useForm({
  name: "",
  email: "",
  phone: "",
  message: ""
});

function submit() {
  form.post(`/contact`, {
    onSuccess: () => form.reset(),
  });
}
</script>

<template>
  <BoxAlert variant="success" v-if="form.wasSuccessful">
    Your message has been successfully sent!
  </BoxAlert>

  <form @submit.prevent="submit">
    <FormInput v-model="form.name" :error="form.errors.name" label="Name" />
    <FormInput v-model="form.email" :error="form.errors.email" label="E-mail" />
    <FormInput v-model="form.phone" :error="form.errors.phone" label="Phone number" />
    <FormTextarea v-model="form.message" :error="form.errors.message" label="Message" />

    <button type="submit">Send message</button>
  </form>
</template>

Simplest way to validate with Intus

Of course, the first step is to install intus using npm:

npm install intus

Then, we want to tap into the submit() function, validate the form data, and, if the validation passes, submit the form; otherwise, get the validation errors and set them as form errors using the form's setError method.

Because the form.setError(errors) doesn't remove the old errors, we need to call form.clearErrors() every time we attempt to submit the form; otherwise, we'll end up having error messages even though the form has been modified and contains valid data.

import intus from "intus";
import {isRequired, isEmail} from "intus/rules";

const form = useForm({
  name: "",
  email: "",
  phone: "",
  message: ""
});

function submit() {
  form.clearErrors();
  
  const validation = intus.validate(form.data(), {
    name: [isRequired()],
    email: [isRequired(), isEmail()],
    phone: [],
    message: [isRequired()],
  });
  
  if (validation.passes()) {
    form.post(`/contact`, {
      onSuccess: () => form.reset(),
    });
  } else {
    form.setError(validation.errors());  
  }
}

And this is the easiest way to do it.

However, there are two things I don't like about it:

  • form fields and rules are not collocated. You have to scroll down to the submit function to see them.
  • there's a bit of repetition. We'll always have to clear the form errors, create a validation object, check if it passes, submit the form, and, if it doesn't pass, set the errors. Ideally, we should just... post the form, and if it has any errors, it should set them on its own.

Composable A

The first solution would be to create and use a vue composable that acts as a decorator for the inertia form object. We pass the form object and rules as arguments, intercept the form submit method, and perform our validation logic.

The API would look something like this:

const form = useValidatedForm(
  useForm({
    name: "",
    email: "",
    phone: "",
    message: ""
  }), 
  {
    name: [isRequired()],
    email: [isRequired(), isEmail()],
    phone: [],
    message: [isRequired()],
  }
);

The useValidatedForm is fairly straightforward. The interesting part is we're using a proxy to intercept the form.submit method.

Here's how it looks:

// useValidatedForm.js
import intus from "intus";

export default function(form, rules) {
   return new Proxy(form, {
    get(target, prop) {
      
      // Intercept `submit` calls
      if (prop === "submit") {
        form.clearErrors();

        const validation = intus.validate(form.data(), rules);

        if (!validation.passes()) {
          form.setError(validation.errors());

          // If validation fails return a function that does nothing
          return () => {};
        }
      }
      
      return target[prop];
    },
  }); 
}

The reason we're intercepting submit() and not post() is because all the inertia form request methods (post, put, delete, etc.), ultimately call the submit method - so we're killing more birds with one stone.

Composable B

This one is pretty much the same as the one above. The difference is we are moving the inertia form creation inside our composable, and instead of passing two separate objects (data and rules) as arguments, we pass a single object whose properties are arrays containing: the default value and the array of rules.

const form = useValidatedForm({
  name: ["", [isRequired()]],
  email: ["", [isRequired(), isEmail()]],
  phone: [""],
  message: ["", [isRequired()]],
});

The useValidatedForm composable now looks like this:

import intus from "intus";
import {useForm} from "@inertiajs/inertia-vue3";

export default function (definition, rememberKey) {
  let fields = {}; // holds fields with default values
  let rules = {}; // holds fields with their validation rules
  
  for (let field in definition) {
    fields[field] = definition[field][0];
    rules[field] = definition[field][1] || [];
  }

  let form = rememberKey ? useForm(rememberKey, fields) : useForm(fields);

  return new Proxy(form, {
    get(target, prop) {
      if (prop === "submit") {
        form.clearErrors();

        const validation = intus.validate(form.data(), rules);

        if (!validation.passes()) {
          form.setError(validation.errors());
          return () => {};
        }
      }

      return target[prop];
    },
  });
}

As you can see, a rememberKey parameter appeared in the composable. Because our inertia form creation has moved inside our composable function, we need to allow a rememberKey to be set. So we'll accept it as an optional second argument, check for its existence and construct the form object accordingly.

Final usage looks like this:

<script setup>
const form = useValidatedForm({
  name: ["", [isRequired()]],
  email: ["", [isRequired(), isEmail()]],
  phone: [""],
  message: ["", [isRequired()]],
});

function submit() {
  form.post(`/contact`, {
    onSuccess: () => form.reset(),
  });
}
</script>

<template>
  <BoxAlert variant="success" v-if="form.wasSuccessful">
    Your message has been successfully sent!
  </BoxAlert>

  <form @submit.prevent="submit">
    <FormInput v-model="form.name" :error="form.errors.name" label="Name" />
    <FormInput v-model="form.email" :error="form.errors.email" label="E-mail" />
    <FormInput v-model="form.phone" :error="form.errors.phone" label="Phone number" />
    <FormTextarea v-model="form.message" :error="form.errors.message" label="Message" />

    <button type="submit">Send message</button>
  </form>
</template>

No validation logic in sight; only supply the rules, and the useValidatedForm composable will do its thing. Super-clean and reusable!