Published on

Handling validation errors with Vue 3 and Inertia.js

Author
  • Name
    Yaz Jallad
    Author bio

    Full stack developer.

After my lastpost about getting Inertia.js running with the new Vue 3 adapter, I thought I'd follow up and share some of the things I'm learning.

Vue 3 is completely new to me, I've followed the RFC and a lot of the development talk, but I never got in there and built anything. One of the things that got me really excited was the way the new Vue solved some of the common issues I faced with reusable code/logic in your app.

I'm sure if you've worked in Vue 2 you would have at some point extracted some logic to a mixin? To be honest, they're great but in some of the apps I worked on (They were really large apps) those mixins started to feel a bit hard to tame. Having to deal with naming data properties and methods in a unique way wasn't always easy.

One problem I solved with a mixin when I was builing ContestKit was handling form errors. In version v0.2.9, Inertia automatically shared validation errors where as previously it was on you to to set this up. Now when you submit a form to your backend for validation, Inertia will have an errors object with the fields that failed validation.

errors: {
  name: 'The name field is required.',
  email: 'The email field is required.',
}

Okay with that background information let's make a dummy form that passed bad data to our backend. I'm going to be using the same repo as my previous article.

For this continuation, I've decided to add in Tailwind CSS and go full on #GOAT Stack.

first thing we're going to do is add Tailwind CSS and the Tailwind custom forms

npm add tailwindcss @tailwindcss/custom-forms

Once you've done that, run

npx tailwind init

That will create a tailwind.config.js file in the root of your file. Next add the custom form plugin.

Next lets update the webpack.mix.js file

const mix = require("laravel-mix");
const path = require("path");

/*
 |--------------------------------------------------------------------------
 | Mix Asset Management
 |--------------------------------------------------------------------------
 |
 | Mix provides a clean, fluent API for defining some Webpack build steps
 | for your Laravel applications. By default, we are compiling the CSS
 | file for the application as well as bundling up all the JS files.
 |
 */

mix.postCss("resources/css/app.css", "public/css", [require("tailwindcss")])
    .js("resources/js/app.js", "public/js")
    .vue({ version: 3 });

// New Alias plugin
mix.alias({
    "@": path.resolve("./resources/js"),
});

Add this to your resources/css/app.css

@import "tailwindcss/base";
@import "tailwindcss/components";
@import "tailwindcss/utilities";

Next run npx mix or npm run dev and test it out. you should get an outpout like this.

 DONE  Compiled successfully in 2936ms                                                                                                                                                                                7:18:01 PM

99% done plugins BuildOutputPlugin


                                
   Laravel Mix v6.0.0-beta.10   
                                

✔ Compiled Successfully in 2936ms
┌───────────────────────────────────┬──────────┐
│ File                              │ Size     │
├───────────────────────────────────┼──────────┤
│ css/app.css                       │ 2.31 MiB │
├───────────────────────────────────┼──────────┤
│ /js/app.js                        │ 1.13 MiB │
├───────────────────────────────────┼──────────┤
│ js/resources_js_Pages_Contact_vu… │ 9.7 KiB  │
├───────────────────────────────────┼──────────┤
│ js/resources_js_Pages_Blog_vue.js │ 9.58 KiB │
├───────────────────────────────────┼──────────┤
│ js/resources_js_Pages_Home_vue.js │ 9.57 KiB │
└───────────────────────────────────┴──────────┘

Okay now we're ready to go. Lets add a contact form to our resources/Pages/Contact.vue page.

<template>
    <div>
        <h1 class="font-bold text-xl my-6">Contact</h1>
        <form @submit.prevent="submit">
            <div class="mt-6">
                <label class="block">
                    <span class="text-gray-700">Name</span>
                    <input
                        v-model="form.name"
                        class="form-input mt-1 block w-full"
                        placeholder="Jane Doe"
                    />
                </label>
            </div>
            <div class="mt-6">
                <label class="block">
                    <span class="text-gray-700">Email</span>
                    <input
                        v-model="form.email"
                        class="form-input mt-1 block w-full"
                        placeholder="[email protected]"
                    />
                </label>
            </div>

            <div class="mt-6">
                <label class="block">
                    <span class="text-gray-700">Message</span>
                    <textarea
                        v-model="form.message"
                        class="form-textarea mt-1 block w-full"
                        rows="3"
                        placeholder="Enter your message"
                    ></textarea>
                </label>
            </div>
            <div class="mt-6">
                <button class="px-5 py-3 bg-gray-900 text-white rounded shadow">
                    Submit
                </button>
            </div>
        </form>
    </div>
</template>

<script>
import { Inertia } from "@inertiajs/inertia";
import { reactive } from "vue";
export default {
    setup() {
        const form = reactive({ name: null, email: null, message: null });
        
        const submit = () => {
            Inertia.post("/contact", form);
        };

        return { form, submit };
    },
};
</script>

This is a pretty simple form. It has 3 fields name, email and message. Right now it doesn't do much. It should look something like this. image

In the setup() method we're declaring the form object. This object is wrapped in a reactive() call which Vue which will mark as reactive data. Under the hood in Vue 3, Vue will create a Proxy.

Next you can see the submit method that will make the Inertia.post() request. The last line in the setup() method returns the form object and submit method which makes them avaliable to our template.

The rest should look pretty familiar, there is v-model bindings on the input that map to the form object. there is a @submit.prevent on the <form> tag which let's us handle the form submission in our submit() method.

Okay now in our routes/web.php we're going to add a POST url for the contact form.

<?php


use App\Http\Controllers\BlogController;
use App\Http\Controllers\ContactController;
use App\Http\Controllers\HomeController;
use Illuminate\Support\Facades\Route;

Route::get('/', [HomeController::class, 'index']);
Route::get('/blog', [BlogController::class, 'index']);
Route::get('/contact', [ContactController::class, 'index']);
Route::post('/contact', [ContactController::class, 'store']);

Next open your app/Http/Controllers/ContactController.php and add a store() method.

<?php

namespace App\Http\Controllers;

use Illuminate\Support\Facades\Request;
use Inertia\Inertia;

class ContactController extends Controller
{
    public function index()
    {
        return Inertia::render('Contact');
    }

    public function store()
    {
        Request::validate([
            'name' => ['required', ],
            'email' => ['required', 'email:rfc,dns,spoof,filter'],
            'message' => ['required', 'min:10', 'max:250'],
        ]);
    }
}

You can see the validation there, we're expecting the name, email and message. This will allow us to get some validation errors to work with.

If you test this out now, you should see the following in your network panel.

image

This should make sense. Inertia is intercepting our POST request here and picking up we have validation errors and that our backend is sending a redirect (302). Next if you inspect the second network requests, the 200 response, you will see the following.

{
   "component":"Contact",
   "props":{
      "errors":{
         "name": "The name field is required.",
         "email": "The email field is required.",
         "message": "The message field is required."
      }
   },
   "url":"\/contact",
   "version":""
}

Inertia is basically saying, using the Contact component (Contact.vue) these are the props. Now that we are here, how do we take this and show validation errors on our form? This is where Vue 3 allows us to create really nice declarative solutions where we could have reached for a mixin in the past.

Make a userErrors.js file and place it in resources/js. In that file add the following.

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

export function useErrors() {
    const { props } = usePage();

    const hasError = (key) => {
        return props.value.errors.hasOwnProperty(key);
    };

    const hasErrors = () => {
        return Object.keys(props.value.errors).length;
    };

    const errorFor = (key) => {
        if (
            !hasErrors() ||
            !props.value.errors[key] ||
            props.value.errors[key].length == 0
        ) {
            return;
        }

        return props.value.errors[key];
    };

    return {
        hasError,
        hasErrors,
        errorFor,
    };
}

Here is where things get fun. In the new Inertia Vue 3 adapter, there is a userPage hook that allows us to access the Inertia $page properties.

Here is what it looks like for reference.

export function usePage() {
  return {
    props: computed(() => page.value.props),
    url: computed(() => page.value.url),
    component: computed(() => page.value.component),
    version: computed(() => page.value.version),
  }
}

You can see, we have access to props, url, component and version. The thing we really want in here is the props since our errors object will be in there.

in our useErrors.js file we deconstruct { props } out from the returned object when we call the usePage() method. From there it's as simple as creating a couple of nice methods to check if we have any errors in the hasErrors, which will return true/false. A hasError which allows you to check if a certian field under validation has an error, and lastly a errorFor method that will retrive the actual error message for the field under validation.

Now we're going to update our Contact.vue form to show validation errors.

<template>
    <div>
        <h1 class="font-bold text-xl my-6">Contact</h1>

        <form @submit.prevent="submit">
            <div class="mt-6">
                <label class="block">
                    <span class="text-gray-700">Name</span>
                    <input
                        v-model="form.name"
                        class="form-input mt-1 block w-full"
                        placeholder="Jane Doe"
                    />
                </label>
                <span class="text-red-400 mt-4" v-if="hasError('name')">{{
                    errorFor("name")
                }}</span>
            </div>
            <div class="mt-6">
                <label class="block">
                    <span class="text-gray-700">Email</span>
                    <input
                        v-model="form.email"
                        class="form-input mt-1 block w-full"
                        placeholder="[email protected]"
                    />
                </label>
                <span class="text-red-400 mt-4" v-if="hasError('email')">{{
                    errorFor("email")
                }}</span>
            </div>

            <div class="mt-6">
                <label class="block">
                    <span class="text-gray-700">Message</span>
                    <textarea
                        v-model="form.message"
                        class="form-textarea mt-1 block w-full"
                        rows="3"
                        placeholder="Enter your message"
                    ></textarea>
                </label>
                <span class="text-red-400 mt-4" v-if="hasError('message')">{{
                    errorFor("message")
                }}</span>
            </div>
            <div class="mt-6">
                <button class="px-5 py-3 bg-gray-900 text-white rounded shadow">
                    Submit
                </button>
            </div>
        </form>
    </div>
</template>

<script>
import { Inertia } from "@inertiajs/inertia";
import { reactive } from "vue";
import { useErrors } from "@/useErrors.js";
export default {
    setup() {
        const form = reactive({ name: null, email: null, message: null });

        const { errorFor, hasError, hasErrors } = useErrors();

        const submit = () => {
            Inertia.post("/contact", form);
        };

        return { form, errorFor, hasError, hasErrors, submit };
    },
};
</script>