Prevalidate forms in InertiaJS and Laravel applications

Constantin Druc · 10 Apr, 2022

Just to be clear, by prevalidation, I mean validating the form before actually submitting it.

There are four steps we need to make:

  1. Keep track of fields that were touched.
  2. Make a validation request when the user focuses out of the form.
  3. If the validation fails, show the errors; but only for the touched fields.
  4. If the validation passes, prevent the route action from executing and redirect back.

Our starting point is a basic contact form component. We’re using a couple of the default Laravel breeze components, we create a form object using the InertiaJS helper, we have a submit method that posts the form to a contact.store route, and then we have the template code rendering the form.

1<script setup>
2import {Head, useForm} from '@inertiajs/inertia-vue3';
3import BreezeButton from '@/Components/Button.vue';
4import BreezeInput from '@/Components/Input.vue';
5import BreezeInputError from '@/Components/InputError.vue';
6import BreezeLabel from '@/Components/Label.vue';
7 
8const form = useForm({
9 name: '',
10 email: '',
11 phone_number: '',
12 subject: '',
13 message: '',
14});
15 
16function submit() {
17 form.post(route('contact.store'));
18};
19</script>
20 
21<template>
22 <Head title="Contact"/>
23 <div class="flex relative justify-center min-h-screen bg-gray-100 items-top dark:bg-gray-900 sm:items-center sm:pt-0">
24 <form class="w-full max-w-lg" @submit.prevent="submit">
25 <div>
26 <BreezeLabel for="name" value="Name"/>
27 <BreezeInput id="name" v-model="form.name" autocomplete="name" class="block mt-1 w-full" type="text"/>
28 <BreezeInputError :message="form.errors.name"/>
29 </div>
30 
31 <div class="mt-4">
32 <BreezeLabel for="email" value="E-mail address"/>
33 <BreezeInput id="email" v-model="form.email" autocomplete="email" class="block mt-1 w-full" type="text"/>
34 <BreezeInputError :message="form.errors.email"/>
35 </div>
36 
37 <div class="mt-4">
38 <BreezeLabel for="phone_number" value="Phone number"/>
39 <BreezeInput id="phone_number" v-model="form.phone_number" autocomplete="phone_number" class="block mt-1 w-full" type="text"/>
40 <BreezeInputError :message="form.errors.phone_number"/>
41 </div>
42 
43 <div class="mt-4">
44 <BreezeLabel for="message" value="Message"/>
45 <textarea id="message" v-model="form.message" class="block mt-1 w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50" rows="5" type="text"/>
46 <BreezeInputError :message="form.errors.message"/>
47 </div>
48 
49 <div class="flex justify-end items-center mt-4">
50 <BreezeButton :class="{ 'opacity-25': form.processing }" :disabled="form.processing" class="ml-4">
51 Send message
52 </BreezeButton>
53 </div>
54 </form>
55 </div>
56</template>

Tracking touched fields

We’ll store the touched fields in a Set. A set is just a collection, like a regular array - the main difference is that a set cannot contain duplicate values. In our case, that will be handy as we won’t have to constantly make sure we don’t track the same field twice.

1let touchedFields = new Set();

Now, to track the touched fields, we’ll add a watcher for the form data, and we’ll compare the new and old form values to extract the fields that have changed. Once we do that, we update/replace the touchedField set using array destructuring.

1watch(() => form.data(), (newData, oldData) => {
2 let changedFields = Object.keys(newData)
3 .filter(field => newData[field] !== oldData[field]);
4 
5 touchedFields = new Set([
6 ...touchedFields,
7 ...changedFields
8 ]);
9});

Validate on focusout

When the user focuses out of the form, we should make an inertia request to the server to validate the form values. We'll send an additional parameter called prevalidate to distinguish between regular and validation requests.

1<script setup>
2// code
3function validate() {
4 Inertia.visit(url, {
5 method: method,
6 data: {
7 ...form.data(),
8 prevalidate: true,
9 },
10 preserveState: true,
11 preserveScroll: true,
12 });
13}
14</script>
15 
16<template>
17 <Head title="Contact"/>
18 <div class="flex relative justify-center min-h-screen bg-gray-100 items-top dark:bg-gray-900 sm:items-center sm:pt-0">
19 <form class="w-full max-w-lg" @submit.prevent="submit" @focusout="validate">
20 //fields
21 </form>
22 </div>
23</template>

Our next step is to get and filter whatever errors we receive on the validation request and only show errors related to the fields present in the touchedFields set.

Show touched fields errors

We can add an onError callback to the inertia visit options to get the errors. Then we can filter and delete all the errors related to fields that are not present in our touchedFields set. Once we do that, we can clear the form errors and set whatever errors remained. If the validation request is successful, we should clear all the form errors.

1function validate() {
2 Inertia.visit(url, {
3 method: method,
4 data: {
5 ...form.data(),
6 prevalidate: true,
7 },
8 preserveState: true,
9 preserveScroll: true,
10 onError: (errors) => {
11 Object.keys(errors)
12 .filter(field => !touchedFields.has(field))
13 .forEach(field => delete errors[field]);
14 
15 form.clearErrors().setError(errors);
16 },
17 onSuccess:() => form.clearErrors(),
18 });
19}

The validate function is executed, and the validation request is made every time the user focuses out of the form. However, we should only do that if the form has changed. So we’ll introduce a needsValidation variable that will be set to true whenever the form data changes and then back to false when the validate function is executed. This will allow us to return early inside the validate function and prevent the validation request from being sent.

1let touchedFields = new Set();
2let needsValidation = false;
3 
4watch(() => form.data(), (newData, oldData) => {
5 let changedFields = Object.keys(newData)
6 .filter(field => newData[field] !== oldData[field]);
7 
8 touchedFields = new Set([
9 ...touchedFields,
10 ...changedFields
11 ]);
12 
13 needsValidation = true;
14});
15 
16function validate() {
17 if (!needsValidation) return;
18 needsValidation = false;
19 
20 Inertia.visit(url, {
21 method: method,
22 data: {
23 ...form.data(),
24 prevalidate: true,
25 },
26 preserveState: true,
27 preserveScroll: true,
28 onError: (errors) => {
29 Object.keys(errors)
30 .filter(field => !touchedFields.has(field))
31 .forEach(field => delete errors[field]);
32 
33 form.clearErrors().setError(errors);
34 },
35 onSuccess:() => form.clearErrors(),
36 });
37}

Prevent route action from executing

Inside our controller action, we’ll check if the request has that prevalidate parameter, and if it does, we’ll redirect back early to stop further execution.

1class ContactController extends Controller
2{
3 public function store()
4 {
5 request()->validate([
6 'name' => ['required', 'min:2'],
7 'email' => ['required', 'email'],
8 'phone_number' => ['required'],
9 'message' => ['required'],
10 ]);
11 
12 if(request()->has('prevalidate')) {
13 return redirect()->back();
14 }
15 
16 return "Contact message was sent";
17 }
18}

And that was it. Our contact form prevalidates now. Whenever a change happens, we send a validation request and assign any errors we receive back.

It would be nice to replicate this functionality with other forms as well, so let’s….

Extract a usePrevalidation composable

I’ll create a new file under resources/js/Composables/usePrevalidation.js, and inside it, we’ll export a function that receives the form and an options object as parameters. The options object will hold the request method (post/put/delete) and the URL to which we need to send the request.

1export function usePrevalidate(form, {method, url}) {
2 
3}

Then we’ll go back to our Contact.vue component, grab all the prevalidation-related code, paste it inside the usePrevalidate function, and replace the hardcoded method and url values with the ones we receive as parameters.

1import {watch} from 'vue';
2import {Inertia} from '@inertiajs/inertia';
3 
4export function usePrevalidate(form, {method, url}) {
5 let touchedFields = new Set();
6 let needsValidation = false;
7 
8 watch(() => form.data(), (newData, oldData) => {
9 let changedFields = Object.keys(newData).filter(field => newData[field] !== oldData[field]);
10 
11 touchedFields = new Set([...changedFields, ...touchedFields]);
12 
13 needsValidation = true;
14 });
15 
16 function validate() {
17 Inertia.visit(url, {
18 method: method,
19 data: {
20 ...form.data(),
21 prevalidate: true,
22 },
23 preserveState: true,
24 preserveScroll: true,
25 onSuccess:() => form.clearErrors(),
26 onError: (errors) => {
27 Object.keys(errors)
28 .filter(field => !touchedFields.has(field))
29 .forEach(field => delete errors[field]);
30 
31 form.clearErrors().setError(errors);
32 },
33 });
34 }
35 
36 return {validate};
37}

Finally, inside our Contact.vue component we can import and use the usePrevalidate composable.

1<script setup>
2import {Head, useForm} from '@inertiajs/inertia-vue3';
3import BreezeButton from '@/Components/Button.vue';
4import BreezeInput from '@/Components/Input.vue';
5import BreezeInputError from '@/Components/InputError.vue';
6import BreezeLabel from '@/Components/Label.vue';
7import {usePrevalidate} from '@/Composables/usePrevalidate';
8 
9const form = useForm({
10 name: '',
11 email: '',
12 phone_number: '',
13 subject: '',
14 message: '',
15});
16 
17const {validate} = usePrevalidate(form, {
18 method: 'post',
19 url: route('contact.store'),
20});
21 
22const submit = () => {
23 form.post(route('contact.store'));
24};
25</script>

And that's it. Whenever we want to prevalidate an inertiajs form, we'll import the usePrevalidate composable and make sure inside our route action we redirect back when the prevalidate parameter is present.

However, if you don't like having to constantly check whether or not the request has that prevalidate parameter, there are ways we can go around it.

Getting around prevalidate checks

Right now every time we're dealing with a form that should prevalidate, we have to check if the prevalidate parameter is present and redirect back. So it would be nice and cleaner to just be able to skip this step.

One way to do it is to tap into the request()->validate() function and throw a custom exception if the validation passes and the prevalidate parameter is present.

If we manage to find a place to throw that custom exception, we can tell Laravel how to handle it; we can tell it to "redirect back".

The request()->validate() method is a request macro, and we can easily overwrite it in our AppServiceProvider. We'll grab a validator instance, validate the data, and if the validation passes, we'll check for the prevalidate parameter and throw a PrevalidationPassedException.

1<?php
2 
3namespace App\Providers;
4 
5use App\Exceptions\PrevalidationPassedException;
6use Illuminate\Http\Request;
7use Illuminate\Support\ServiceProvider;
8 
9class AppServiceProvider extends ServiceProvider
10{
11 /**
12 * Register any application services.
13 *
14 * @return void
15 */
16 public function register()
17 {
18 //
19 }
20 
21 /**
22 * Bootstrap any application services.
23 *
24 * @return void
25 */
26 public function boot()
27 {
28 Request::macro('validate', function (array $rules, ...$params) {
29 validator()->validate($this->all(), $rules, ...$params);
30 if ($this->has('prevalidate')) {
31 throw new PrevalidationPassedException;
32 }
33 });
34 }
35}

The PrevalidationPassedException will be handled by redirecting back, and we’ll also stop it from being logged into the log files by adding a report method returning true.

1<?php
2 
3namespace App\Exceptions;
4 
5use Exception;
6 
7class PrevalidationPassedException extends Exception
8{
9 public function report()
10 {
11 return true;
12 }
13 
14 public function render()
15 {
16 return redirect()->back();
17 }
18}

Back to our controller, we can just remove the prevalidate check. Next time a prevalidate request that passes the validation is sent, a PrevalidationPassedException will be thrown and handled by redirecting back.

However, this solution does not work with form request objects; it only works with inline validation. To make it work with form request objects, we need to add an afterResolving callback for the ValidatesWhenResolved class.

To reduce some of the duplications, we’ll add another throwIfPrevalidatemacro.

1<?php
2 
3namespace App\Providers;
4 
5use App\Exceptions\PrevalidationPassedException;
6use Illuminate\Contracts\Validation\ValidatesWhenResolved;
7use Illuminate\Http\Request;
8use Illuminate\Support\ServiceProvider;
9 
10class AppServiceProvider extends ServiceProvider
11{
12 /**
13 * Register any application services.
14 *
15 * @return void
16 */
17 public function register()
18 {
19 //
20 }
21 
22 /**
23 * Bootstrap any application services.
24 *
25 * @return void
26 */
27 public function boot()
28 {
29 $this->app->afterResolving(ValidatesWhenResolved::class, function ($request) {
30 $request->throwIfPrevalidate();
31 });
32 
33 Request::macro('throwIfPrevalidate', function () {
34 if ($this->has('prevalidate')) {
35 throw new PrevalidationPassedException;
36 }
37 });
38 
39 Request::macro('validate', function (array $rules, ...$params) {
40 validator()->validate($this->all(), $rules, ...$params);
41 $this->throwIfPrevalidate();
42 });
43 }
44}