Prevalidate forms in InertiaJS and Laravel applications
Just to be clear, by prevalidation, I mean validating the form before actually submitting it.
There are four steps we need to make:
- Keep track of fields that were touched.
- Make a validation request when the user focuses out of the form.
- If the validation fails, show the errors; but only for the touched fields.
- 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 message52 </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 ...changedFields8 ]);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 //fields21 </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 ...changedFields11 ]);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 ServiceProvider10{11 /**12 * Register any application services.13 *14 * @return void15 */16 public function register()17 {18 //19 }20 21 /**22 * Bootstrap any application services.23 *24 * @return void25 */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 throwIfPrevalidate
macro.
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 ServiceProvider11{12 /**13 * Register any application services.14 *15 * @return void16 */17 public function register()18 {19 //20 }21 22 /**23 * Bootstrap any application services.24 *25 * @return void26 */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}
-
1Determining active routes05:24
-
2Create a searchable list09:05
-
3Handling regular forms16:41
-
4Toast notifications17:32
-
5Inline forms validation09:15
-
6Authorization08:45
-
7Structuring layouts10:43
-
8Infinite scrolling with InertiaJs and Laravel15:57
-
9Toggling likes13:27
-
10Notifications with Laravel Echo and Pusher in InertiaJS applications (part one)14:47
-
11Realtime Notifications with Laravel Echo and Pusher in InertiaJS applications (part two)09:07
-
12Building a user following / unfollowing system with Laravel and InertiaJS22:39
-
13Changing button labels on hover and InertiaJS progress indicators05:34
-
14Building the home feed and infinite scroll component with Laravel and InertiaJS17:52
-
15Building a form component to post new tweets with Laravel and InertiaJS19:38
-
16Media attachments with Laravel and InertiaJS41:46
-
17Avatar image input component with VueJS, Inertia, and Tailwind CSS14:44
-
18Bugfixing the avatar input component04:16
-
19New InertiaJs form helper - less boilerplate, more awesomeness08:30
-
20InertiaJS scroll management13:34
-
21InertiaJS submitting form arrays and error handling11:46
-
22Create a searchable select field with InertiaJS and vue multiselect13:10
-
23InertiaJS data evaluation in Laravel and VueJS applications04:37
-
24Creating a datatable with Laravel and InertiaJS10:58
-
25Customize the default Jetstream view responses in Laravel apps using the Inertia stack03:46
-
26Fuzzy searching with Fuse.js in Laravel & InertiaJS applications10:38
-
27Handling translations in Laravel and InertiaJS applications12:24
-
28Replace auth redirect with modal dialog in InertiaJS & Laravel applications06:49
-
Prevalidate forms in InertiaJS and Laravel applications16:06
-
30Prevent recently logged out users from seeing private pages when navigating back04:08
-
31InertiaJS frontend validation using Intus11:50
-
32InertiaJS & Vue 3 toast notifications18:33
-
1Determining active routes05:24
-
2Create a searchable list09:05
-
3Handling regular forms16:41
-
4Toast notifications17:32
-
5Inline forms validation09:15
-
6Authorization08:45
-
7Structuring layouts10:43
-
8Infinite scrolling with InertiaJs and Laravel15:57
-
9Toggling likes13:27
-
10Notifications with Laravel Echo and Pusher in InertiaJS applications (part one)14:47
-
11Realtime Notifications with Laravel Echo and Pusher in InertiaJS applications (part two)09:07
-
12Building a user following / unfollowing system with Laravel and InertiaJS22:39
-
13Changing button labels on hover and InertiaJS progress indicators05:34
-
14Building the home feed and infinite scroll component with Laravel and InertiaJS17:52
-
15Building a form component to post new tweets with Laravel and InertiaJS19:38
-
16Media attachments with Laravel and InertiaJS41:46
-
17Avatar image input component with VueJS, Inertia, and Tailwind CSS14:44
-
18Bugfixing the avatar input component04:16
-
19New InertiaJs form helper - less boilerplate, more awesomeness08:30
-
20InertiaJS scroll management13:34
-
21InertiaJS submitting form arrays and error handling11:46
-
22Create a searchable select field with InertiaJS and vue multiselect13:10
-
23InertiaJS data evaluation in Laravel and VueJS applications04:37
-
24Creating a datatable with Laravel and InertiaJS10:58
-
25Customize the default Jetstream view responses in Laravel apps using the Inertia stack03:46
-
26Fuzzy searching with Fuse.js in Laravel & InertiaJS applications10:38
-
27Handling translations in Laravel and InertiaJS applications12:24
-
28Replace auth redirect with modal dialog in InertiaJS & Laravel applications06:49
-
Prevalidate forms in InertiaJS and Laravel applications16:06
-
30Prevent recently logged out users from seeing private pages when navigating back04:08
-
31InertiaJS frontend validation using Intus11:50
-
32InertiaJS & Vue 3 toast notifications18:33