Home / Blog / The Vue Form Component Pattern: Robust Forms Without the Fuss
The Vue Form Component Pattern: Robust Forms Without the Fuss

The Vue Form Component Pattern: Robust Forms Without the Fuss

Daniel Kelly
Daniel Kelly
Updated: December 16th 2024
This entry is part 2 of 2 in the series Vue Component Design

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.

The Problem with Direct Data Binding

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!

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!

A Better Pattern: The Form Component Pattern

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.

Step 1: Create the Dedicated Form Component

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>

Step 2: Define the Model Connection

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)
})

Step 3: Clone the modelValue to Create Local State

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
})

Step 4: Create the Template and Bind the Inputs to the LOCAL Data

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.)

Step 5: Handle Form Submission

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.

Step 6: Synchronize Local Data with Parent Updates

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.

Putting It All Together

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>

Benefits of the Form Component Pattern

This approach has a variety of benefits. Let’s explore them below.

The Form Component Pattern Only Commits the Data on Submit

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.

this-form-is-behaving.mp4

The Form Component Pattern Support Native Input Validation

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.

native-form-validation.mp4

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 Form Component Pattern Supports an Intuitive Component Interface

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"/>

Enhance the UserForm Component with TypeScript

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
})
Screenshot 2024-12-05 at 2.04.16 PM.png

A useForm Composable

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!?

Form Component Pattern Wrap Up

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:

  • The Branching Component Pattern
  • The List and ListItem Pattern
  • The Smart vs Dumb Component Pattern
  • and more

Related Courses

Start learning Vue.js for free

Daniel Kelly
Daniel Kelly
Daniel is the lead instructor at Vue School and enjoys helping other developers reach their full potential. He has 10+ years of developer experience using technologies including Vue.js, Nuxt.js, and Laravel.

Comments

Latest Vue School Articles

Prepare for Vue.js 3

Prepare for Vue.js 3

The new version of Vue.js is right around the corner. There are quite some new, exciting features coming in Vue 3.
Alex Kyriakidis
Alex Kyriakidis
Why people got upset with Vue 3

Why people got upset with Vue 3

Monday I woke to see a ton of people tweeting about some new feature of Vue 3! I get excited and see this tweet from Evan You, the creator of Vue, in the event you didn’t know.
Alex Kyriakidis
Alex Kyriakidis

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!

Follow us on Social

© All rights reserved. Made with ❤️ by BitterBrains, Inc.