Forms are a fundamental part of web applications, yet implementing them in a way that feels natural to users while maintaining clean code can be challenging. In this article, we'll explore a Vue form component pattern that achieves both goals elegantly without any heavy dependencies or complicated libraries.
When building forms in Vue, it's tempting to bind form fields directly the source of truth for your data using v-model (for example, a user in your global store or a post on your page component).
However, this approach has a significant drawback: every keystroke immediately updates the source data. This behavior often contradicts users' mental model of forms, where changes should only be committed when explicitly submitted. It also bypasses browser native field validations leaving you to implement a custom solution.
In this screen share, when the user updates their name in the edit user form, it starts changing the name of the logged in user in the navbar even before the submit button is pressed!
Let's examine a pattern that solves these issues and provides an intuitive API by creating a dedicated UserForm
component. The component works by keeping a local copy of the form data, and then only committing changes on submit. Let’s walk through it together step by step.
First, we’ll encapsulate the entire form in it’s own component.
<!-- UserForm.vue-->
<script setup></script>
<template></template>
We’ll also take an API driven development approach where we start with how we’d LIKE to use it in the parent. I’d say a v-model
is pretty intuitive.
<!--Parent.vue-->
<script setup>
// the source data
const user = ref({
name: "John Wick",
email: "[email protected]",
age: 45
});
<script>
<template>
<UserForm v-model="user" />
</template>
Next, we need to establish a connection in UserForm.vue
with the parent component using Vue's defineModel
macro. This creates a two-way binding while giving us control over when updates happen.
// UserForm.vue
const modelValue = defineModel()
This single line creates a special ref that connects to the parent's v-model. When we update modelValue.value
, it automatically updates the parent's data. It’s basically syntactic sugar for this:
const props = defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])
const modelValue = computed({
get(){
return props.modelValue
},
set(newValue){
emit('update:modelValue', newValue)
})
After supporting v-model, we create a local copy of the form data. This is important for maintaining a separate "draft" of the form that users can edit without affecting the parent data.
const modelValue = defineModel()
const form = ref(clone(modelValue.value))
// Helper function to create deep copies of objects
const clone = (obj)=> JSON.parse(JSON.stringify(obj)
The clone
function creates a deep copy to ensure our local form state is completely independent of the parent data. Why? Because objects are passed by reference and not by value, so it’s important to almost always clone object type props.
“ It’s important to almost always clone object type props”
We also provide default values in case modelValue
is undefined (like in a "create new" scenario).
const form = ref(clone(modelValue?.value) || {
name: "",
email: "",
age: null
})
With the local form data now in place to hold our “draft”, we build out the template form and inputs with native validation attributes and bind them to our local state.
<template>
<form>
<label>
Name
<input type="text" v-model="form.name" required/>
</label>
<label>
Email
<input type="email" v-model="form.email" required />
</label>
<label>
Age
<input type="number" v-model="form.age" min="3" required />
</label>
<button>Submit</button>
</form>
</template>
The most important part in this step is that we bind the inputs to the LOCAL data. This is what keeps the data encapsulated until it’s set free with the submit button.
Oh, and yes you did hear that correctly, this technique does work with native HTML field validation with zero extra effort!! 🔥 (More on that in a minute.)
Now we can listen to the submit event…
<form @submit.prevent="handleSubmit">
and implement a simple submit handler. It’s a one-liner that updates the parent data (emits the “update:modelValue” event) only when the form is explicitly submitted.
const handleSubmit = ()=> modelValue.value = clone(form.value)
This function creates a fresh copy of the form data and updates the parent model. Using clone
here prevents any unintended reactivity connections between the form and parent data.
At this point, it might seem like we’re done. All the data coming out of the form component is working as expected. But there is one small caveat left to handle. We need to listen for changes to the parent data and update our local form data accordingly. This ensures that if the parent data changes (perhaps due to an update from the API), our form stays in sync.
watch(modelValue, () => {
form.value = clone(modelValue.value)
}, { deep: true })
The deep: true
option ensures we detect changes even in nested properties of the form data.
Breaking it down step by step helps understanding but makes it seem longer than it really is. Here's the complete component with comments explaining each part. It’s barely any code!
<script setup>
// support v-model
const modelValue = defineModel()
// clone the modelValue to local data
// and provide a fallback user if none provided
const form = ref(clone(modelValue.value) || {
name: "",
email: "",
age: null
})
// only update the modelValue when the form is submitted
function handleSubmit() {
modelValue.value = clone(form.value)
}
// Reset form when prop changes
watch(modelValue, () => {
form.value = clone(modelValue.value)
}, { deep: true })
// utility clone function
const clone = (obj)=> JSON.parse(JSON.stringify(obj))
</script>
<template>
<form @submit.prevent="handleSubmit">
<label>
Name
<input type="text" v-model="form.name" required />
</label>
<label>
Email
<input type="email" v-model="form.email" required />
</label>
<label>
Age
<input type="number" v-model="form.age" min="3" required />
</label>
<button>Submit</button>
</form>
</template>
This approach has a variety of benefits. Let’s explore them below.
The first and most important benefit, is that it meets the typical user expectation for the submit button. The component maintains its own form state using ref(clone(modelValue.value))
, ensuring that changes remain local until explicitly submitted.
The next benefit is that you can rely on native HTML field validation to prevent invalid data from being committed (whatever that means for your situation… ie displayed to the page, sent to the DB, etc).
By using standard HTML form elements and attributes (required
, type="email"
, min="3"
), we get robust client-side validation for free.
Why does this work? Because the browser handles validation before the form can be submitted. It prevents the form submit event from ever firing!
The final benefit is the DX. I mean, come on, what’s more intuitive than this? No doubt the component presents a clean interface to its parent.
<UserForm v-model="user"/>
Another way we can make our component even better is by providing type safety with TypeScript. If we create an interface for the user and use it with defineModel
and the local form ref, then the IDE will quickly let us that we’re passing the wrong form of data from the parent.
interface UserForm {
name: string;
email: string;
age: number | null;
}
const modelValue = defineModel<UserForm>()
const form = ref<UserForm>(clone(modelValue.value) || {
name: "",
email: "",
age: null
})
While the script section of our form component isn’t that complex, we can extract most of the logic to a composable so that we can easily create other types of forms that follow the same pattern (think PostForm
or CommentForm
). This is what that composable might look like.
import { type ModelRef, ref, watch } from 'vue';
// take in the default value and the component modelValue
export function useForm<T>(defaultValue: T, modelValue?: ModelRef<T | null | undefined>) {
// create the local copy here
const form = ref<T>(clone(modelValue?.value) || defaultValue);
// add support for no v-model provided with a conditional
if (modelValue) {
// still supporting data change coming DOWN from the parent
watch(modelValue, () => {
form.value = clone(modelValue.value);
}, { deep: true });
}
function clone(obj: any) {
return JSON.parse(JSON.stringify(obj));
}
function handleSubmit() {
if (!modelValue) return; // also required if no v-model
modelValue.value = clone(form.value);
}
// expose the local data and the submit function to the form component
return {
form,
handleSubmit
};
}
Then whenever we want to create a new component that follows the form component pattern this is all that’s required.
<!--TaskForm.vue-->
<script setup lang="ts">
interface TaskForm {
body: string;
title: string;
assignedTo: Uuid
}
const modelValue = defineModel<TaskForm>();
const { form, handleSubmit } = useForm<TaskForm>({
body: "",
title: "",
assignedTo: ""
}, modelValue);
</script>
<template>
<form @submit.prevent="handleSubmit">
<label>
Title
<input type="text" v-model="form.title" required />
</label>
<label>
Body
<textarea type="email" v-model="form.body" required />
</label>
<label>
Assigned To
<select type="email" v-model="form.body" required>
<option value="15ed65a7-541c-4f37-9938-473d7d0d7eac">Jane</option>
<option value="79f9803c-1f8a-4245-98a2-ce6a3c9d46bd">John</option>
</select>
</label>
<button>
{{ modelValue ? 'Edit' : 'Create' }} Task
</button>
</form>
</template>
Pretty sweet right!?
The form component pattern provides a robust foundation for building form interactions that feel natural to users while maintaining clean, maintainable code. The combination of local state management, native HTML validation, and a simple v-model interface makes it a pleasure to work with, while TypeScript and the useForm
composable adds an extra layer of usability.
By following this pattern, you can create forms that are both user-friendly and developer-friendly, with the flexibility to add additional features as needed.
If you found this pattern helpful, checkout our full video course dedicated to Vue.js component patterns. Vue Component Design explores not only this pattern but also patterns like:
Our goal is to be the number one source of Vue.js knowledge for all skill levels. We offer the knowledge of our industry leaders through awesome video courses for a ridiculously low price.
More than 200.000 users have already joined us. You are welcome too!
© All rights reserved. Made with ❤️ by BitterBrains, Inc.