Building a DRY Form System with Inertia.js
Every Laravel developer knows the tedium of writing yet another form. In a typical CRUD application, form-related code can easily account for 30-40% of your frontend codebase. While Inertia.js elegantly bridges Laravel and frontend frameworks like Vue, it doesn’t inherently solve the problem of form repetition.
By the end of this guide, you’ll have a system where new forms require just 10-15 lines of code, both for creation and editing operations. Here’s a quick before and after:
Before optimization:
<script setup>
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout.vue";
import { Head } from "@inertiajs/vue3";
import { ref } from "vue";
import { useForm } from "@inertiajs/vue3";
const form = useForm({
name: "",
email: "",
phone: "",
address: "",
city: "",
state: "",
postal_code: "",
notes: "",
});
const processing = ref(false);
const errors = ref({});
function submit() {
processing.value = true;
form.post("/customers", {
onSuccess: () => {
// Handle success
processing.value = false;
},
onError: (errors) => {
// Handle validation errors
processing.value = false;
},
});
}
</script>
<template>
<Head title="Create Customer" />
<AuthenticatedLayout>
<template #header>
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
Create Customer
</h2>
</template>
<div class="py-12">
<div class="max-w-7xl mx-auto py-10 sm:px-6 lg:px-8">
<div class="md:grid md:grid-cols-3 md:gap-6">
<div class="md:col-span-1">
<div class="px-4 sm:px-0">
<h3 class="text-lg font-medium text-gray-900">
Customer Information
</h3>
<p class="mt-1 text-sm text-gray-600">
Enter the customer's basic information.
</p>
</div>
</div>
<div class="mt-5 md:mt-0 md:col-span-2">
<form @submit.prevent="submit">
<div class="shadow overflow-hidden sm:rounded-md">
<div class="px-4 py-5 bg-white sm:p-6">
<div class="grid grid-cols-6 gap-6">
<div class="col-span-6 sm:col-span-4">
<label
for="name"
class="block text-sm font-medium text-gray-700"
>Name</label
>
<input
type="text"
name="name"
id="name"
v-model="form.name"
class="mt-1 focus:ring-indigo-500 focus:border-indigo-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"
/>
<div
v-if="errors.name"
class="text-red-500 text-sm mt-1"
>
{{ errors.name[0] }}
</div>
</div>
<div class="col-span-6 sm:col-span-4">
<label
for="email"
class="block text-sm font-medium text-gray-700"
>Email</label
>
<input
type="email"
name="email"
id="email"
v-model="form.email"
class="mt-1 focus:ring-indigo-500 focus:border-indigo-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"
/>
<div
v-if="errors.email"
class="text-red-500 text-sm mt-1"
>
{{ errors.email[0] }}
</div>
</div>
<!-- Repeat similar blocks for other fields -->
</div>
</div>
<div
class="px-4 py-3 bg-gray-50 text-right sm:px-6"
>
<button
type="submit"
:disabled="processing"
class="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
<span v-if="processing"
>Processing...</span
>
<span v-else>Save</span>
</button>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
</AuthenticatedLayout>
</template>
After optimization:
<!-- pages/Customers/Create.vue -->
<script setup>
import { useCustomForm } from "@/Composables/useCustomForm";
import FormPage from "@/Components/Form/FormPage.vue";
import TextInput from "@/Components/Form/TextInput.vue";
import EmailInput from "@/Components/Form/EmailInput.vue";
import TextareaInput from "@/Components/Form/TextareaInput.vue";
const form = useCustomForm({
endpoint: "/customers",
initialValues: {
name: "",
email: "",
phone: "",
address: "",
city: "",
state: "",
postal_code: "",
notes: "",
},
redirectOnSuccess: "/customers",
});
</script>
<template>
<FormPage
title="Create Customer"
description="Enter the customer's basic information."
>
<TextInput
v-model="form.data.name"
label="Name"
:error="form.errors.name"
required
/>
<EmailInput
v-model="form.data.email"
label="Email"
:error="form.errors.email"
required
/>
<TextInput
v-model="form.data.phone"
label="Phone"
:error="form.errors.phone"
/>
<TextInput
v-model="form.data.address"
label="Address"
:error="form.errors.address"
/>
<TextInput
v-model="form.data.city"
label="City"
:error="form.errors.city"
/>
<TextInput
v-model="form.data.state"
label="State"
:error="form.errors.state"
/>
<TextInput
v-model="form.data.postal_code"
label="Postal Code"
:error="form.errors.postal_code"
/>
<TextareaInput
v-model="form.data.notes"
label="Notes"
:error="form.errors.notes"
/>
<template #form-actions>
<button
type="button"
:disabled="form.processing.value"
@click="form.submit()"
class="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
<span v-if="form.processing.value">Processing...</span>
<span v-else>Save Customer</span>
</button>
</template>
</FormPage>
</template>
Setting Up the Foundation
Our tech stack:
- Laravel 10+
- Inertia.js 1.0+
- Vue 3 (Composition API)
- Tailwind CSS
Our form system has three main layers:
- Base Input Components: Reusable UI elements for different input types
- Form Structure Components: Layout components for organizing forms consistently
- Form Submission Logic: A composable that handles the submission flow and state management
Creating Reusable Form Components
Base Input Components
First, let’s create a BaseInput.vue component:
<!-- components/Form/BaseInput.vue -->
<script setup>
import { computed } from "vue";
const props = defineProps({
id: {
type: String,
default: () => `input-${Math.random().toString(36).substring(2, 9)}`,
},
modelValue: { type: [String, Number, Boolean, Array, Object], default: "" },
label: { type: String, default: "" },
error: { type: [String, Array, Object], default: null },
helpText: { type: String, default: "" },
required: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
});
const emit = defineEmits(["update:modelValue"]);
const hasError = computed(() => !!props.error);
const inputClasses = computed(() => {
return [
"mt-1 block w-full rounded-md shadow-sm sm:text-sm",
hasError.value
? "border-red-300 text-red-900 placeholder-red-300 focus:border-red-500 focus:ring-red-500"
: "border-gray-300 focus:border-indigo-500 focus:ring-indigo-500",
];
});
const updateValue = (value) => {
emit("update:modelValue", value);
};
</script>
<template>
<div class="mb-4">
<label
v-if="label"
:for="id"
class="block text-sm font-medium text-gray-700"
>
{{ label }} <span v-if="required" class="text-red-500">*</span>
</label>
<slot
:id="id"
:update-value="updateValue"
:input-classes="inputClasses"
></slot>
<p v-if="error" class="mt-1 text-sm text-red-600">
{{ Array.isArray(error) ? error[0] : error }}
</p>
<p v-else-if="helpText" class="mt-1 text-sm text-gray-500">
{{ helpText }}
</p>
</div>
</template>
Now, let’s create a text input component:
<!-- components/Form/TextInput.vue -->
<script setup>
import BaseInput from "./BaseInput.vue";
const props = defineProps({
modelValue: { type: String, default: "" },
type: { type: String, default: "text" },
label: { type: String, default: "" },
placeholder: { type: String, default: "" },
error: { type: [String, Array, Object], default: null },
helpText: { type: String, default: "" },
required: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
});
const emit = defineEmits(["update:modelValue"]);
</script>
<template>
<BaseInput
:model-value="modelValue"
:label="label"
:error="error"
:help-text="helpText"
:required="required"
:disabled="disabled"
@update:model-value="emit('update:modelValue', $event)"
>
<template #default="{ id, updateValue, inputClasses }">
<input
:id="id"
:type="type"
:value="modelValue"
:placeholder="placeholder"
:disabled="disabled"
:required="required"
:class="inputClasses"
@input="updateValue($event.target.value)"
/>
</template>
</BaseInput>
</template>
Form Structure Components
Let’s create components for structuring our forms:
<!-- components/Form/FormSection.vue -->
<script setup>
defineProps({
title: { type: String, default: "" },
description: { type: String, default: "" },
});
</script>
<template>
<div class="md:grid md:grid-cols-3 md:gap-6 mb-8">
<div class="md:col-span-1">
<div class="px-4 sm:px-0">
<h3 v-if="title" class="text-lg font-medium text-gray-900">
{{ title }}
</h3>
<p v-if="description" class="mt-1 text-sm text-gray-600">
{{ description }}
</p>
</div>
</div>
<div class="mt-5 md:mt-0 md:col-span-2">
<div class="shadow overflow-hidden sm:rounded-md">
<div class="px-4 py-5 bg-white sm:p-6">
<div>
<slot></slot>
</div>
</div>
</div>
</div>
</div>
</template>
For form actions:
<!-- components/Form/FormActions.vue -->
<script setup>
// No props needed
</script>
<template>
<div class="px-4 py-3 bg-gray-50 text-right sm:px-6">
<slot></slot>
</div>
</template>
And a FormPage.vue component for the base layout:
<!-- components/Form/FormPage.vue -->
<script setup>
import FormSection from "@/Components/Form/FormSection.vue";
import FormActions from "@/Components/Form/FormActions.vue";
defineProps({
title: { type: String, default: "" },
description: { type: String, default: "" },
});
</script>
<template>
<div class="max-w-7xl mx-auto py-10 sm:px-6 lg:px-8">
<form @submit.prevent>
<FormSection :title="title" :description="description">
<slot></slot>
</FormSection>
<slot name="actions">
<FormActions>
<slot name="form-actions"></slot>
</FormActions>
</slot>
</form>
</div>
</template>
Building a Form Submission Composable
The heart of our system is a powerful form composable that handles all the form logic:
// composables/useCustomForm.js
import { ref, reactive, computed } from 'vue'
import { router } from '@inertiajs/vue3'
import { cloneDeep } from 'lodash'
export function useCustomForm(options) {
const {
endpoint,
method = 'POST',
initialValues = {},
resetOnSuccess = true,
redirectOnSuccess = null,
onSuccess = null,
onError = null
} = options
// Create reactive data
const original = cloneDeep(initialValues)
const data = reactive(cloneDeep(initialValues))
const errors = ref({})
const processing = ref(false)
const successful = ref(false)
const recentlySuccessful = ref(false)
// Determine if form is for create or edit
const isEditForm = computed(() => {
return method.toUpperCase() === 'PUT' || method.toUpperCase() === 'PATCH'
})
// Check if any fields are dirty (modified)
const isDirty = computed(() => {
return JSON.stringify(data) !== JSON.stringify(original)
})
// Reset form to original values
const reset = () => {
Object.assign(data, cloneDeep(original))
errors.value = {}
successful.value = false
recentlySuccessful.value = false
}
// Process the form submission
const submit = () => {
processing.value = true
// Use the appropriate Inertia method based on the form method
const routeMethod = method.toLowerCase()
// Handle file uploads
const hasFiles = hasFilesDeep(data)
const formData = hasFiles ? objectToFormData(data) : data
const options = {
onStart: () => {
processing.value = true
errors.value = {}
successful.value = false
},
onSuccess: (page) => {
successful.value = true
recentlySuccessful.value = true
if (resetOnSuccess) {
reset()
}
if (redirectOnSuccess) {
router.visit(redirectOnSuccess)
}
if (onSuccess) {
onSuccess(page)
}
// Reset "recentlySuccessful" after 2 seconds
setTimeout(() => {
recentlySuccessful.value = false
}, 2000)
},
onError: (validationErrors) => {
errors.value = validationErrors
if (onError) {
onError(validationErrors)
}
},
onFinish: () => {
processing.value = false
}
}
// Use special options for file uploads
const finalOptions = hasFiles ? {
...options,
forceFormData: true,
preserveScroll: true,
} : options
router[routeMethod](endpoint, formData, finalOptions)
}
// Helper to set individual field values
const setField = (field, value) => {
data[field] = value
// Clear the error for this field when it's changed
if (errors.value[field]) {
delete errors.value[field]
}
}
// Set all data at once (useful for edit forms)
const setData = (newData) => {
Object.assign(data, newData)
Object.assign(original, cloneDeep(newData))
errors.value = {}
}
// Helper to check if object contains File instances
const hasFilesDeep = (obj) => {
if (!obj) return false
if (obj instanceof File) return true
if (Array.isArray(obj)) {
return obj.some(item => hasFilesDeep(item))
}
if (typeof obj === 'object') {
return Object.values(obj).some(val => hasFilesDeep(val))
}
return false
}
// Convert object to FormData for file uploads
const objectToFormData = (obj, formData = new FormData(), parentKey = '') => {
if (obj === null || obj === undefined) return formData
if (obj instanceof File) {
formData.append(parentKey, obj)
return formData
}
if (Array.isArray(obj)) {
obj.forEach((item, index) => {
objectToFormData(item, formData, `${parentKey}[${index}]`)
})
return formData
}
if (typeof obj === 'object' && !(obj instanceof File) && !(obj instanceof Date)) {
Object.keys(obj).forEach(key => {
const propName = parentKey ? `${parentKey}[${key}]` : key
objectToFormData(obj[key], formData, propName)
})
return formData
}
// Handle Date objects
if (obj instanceof Date) {
formData.append(parentKey, obj.toISOString())
return formData
}
// Handle primitive values
formData.append(parentKey, obj)
return formData
}
// Expose everything we need
return {
data,
errors,
processing,
successful,
recentlySuccessful,
isDirty,
isEditForm,
reset,
submit,
setField,
setData
}
}
Implementing Create and Edit Pages
Create Page
<!-- pages/Customers/Create.vue -->
<script setup>
import { useCustomForm } from "@/Composables/useCustomForm";
import FormPage from "@/Components/Form/FormPage.vue";
import TextInput from "@/Components/Form/TextInput.vue";
import EmailInput from "@/Components/Form/EmailInput.vue";
import TextareaInput from "@/Components/Form/TextareaInput.vue";
const form = useCustomForm({
endpoint: "/customers",
initialValues: {
name: "",
email: "",
phone: "",
address: "",
city: "",
state: "",
postal_code: "",
notes: "",
},
redirectOnSuccess: "/customers",
});
</script>
<template>
<FormPage
title="Create Customer"
description="Enter the customer's basic information."
>
<TextInput
v-model="form.data.name"
label="Name"
:error="form.errors.name"
required
/>
<EmailInput
v-model="form.data.email"
label="Email"
:error="form.errors.email"
required
/>
<TextInput
v-model="form.data.phone"
label="Phone"
:error="form.errors.phone"
/>
<TextInput
v-model="form.data.address"
label="Address"
:error="form.errors.address"
/>
<TextInput
v-model="form.data.city"
label="City"
:error="form.errors.city"
/>
<TextInput
v-model="form.data.state"
label="State"
:error="form.errors.state"
/>
<TextInput
v-model="form.data.postal_code"
label="Postal Code"
:error="form.errors.postal_code"
/>
<TextareaInput
v-model="form.data.notes"
label="Notes"
:error="form.errors.notes"
/>
<template #form-actions>
<button
type="button"
:disabled="form.processing.value"
@click="form.submit()"
class="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
<span v-if="form.processing.value">Processing...</span>
<span v-else>Save Customer</span>
</button>
</template>
</FormPage>
</template>
Edit Page
<!-- pages/Customers/Edit.vue -->
<script setup>
import { useCustomForm } from "@/Composables/useCustomForm";
import FormPage from "@/Components/Form/FormPage.vue";
import TextInput from "@/Components/Form/TextInput.vue";
import EmailInput from "@/Components/Form/EmailInput.vue";
import TextareaInput from "@/Components/Form/TextareaInput.vue";
const props = defineProps({
customer: {
type: Object,
required: true,
},
});
const form = useCustomForm({
endpoint: `/customers/${props.customer.id}`,
method: "PUT",
initialValues: props.customer,
redirectOnSuccess: "/customers",
});
</script>
<template>
<FormPage
title="Edit Customer"
description="Update the customer's information."
>
<TextInput
v-model="form.data.name"
label="Name"
:error="form.errors.name"
required
/>
<EmailInput
v-model="form.data.email"
label="Email"
:error="form.errors.email"
required
/>
<TextInput
v-model="form.data.phone"
label="Phone"
:error="form.errors.phone"
/>
<TextInput
v-model="form.data.address"
label="Address"
:error="form.errors.address"
/>
<TextInput
v-model="form.data.city"
label="City"
:error="form.errors.city"
/>
<TextInput
v-model="form.data.state"
label="State"
:error="form.errors.state"
/>
<TextInput
v-model="form.data.postal_code"
label="Postal Code"
:error="form.errors.postal_code"
/>
<TextareaInput
v-model="form.data.notes"
label="Notes"
:error="form.errors.notes"
/>
<template #form-actions>
<button
type="button"
:disabled="form.processing.value"
@click="form.submit()"
class="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
<span v-if="form.processing.value">Processing...</span>
<span v-else>Update Customer</span>
</button>
</template>
</FormPage>
</template>
Conclusion
We’ve built a streamlined form system for Inertia.js applications that dramatically reduces code duplication. The benefits are clear:
- Reduced code duplication: Eliminated repetitive form boilerplate
- Consistent user experience: All forms have the same look and behavior
- Faster development: Creating new forms requires minimal code
- Improved maintainability: Changes can be made in one place
By investing time in creating this system, you’ll save countless hours of development time and create a more consistent, maintainable codebase.