Isolate the state of a form in Vue 3 using the “Pending v-model” pattern

Isolate the state of a form in Vue 3 using the “Pending v-model” pattern

Today I want to share a simple but very useful pattern for working with forms in Vue 3, which I often use in my projects. The essence of it is to isolate the internal state of the form and not pass every change immediately to the parent component via v-model. Instead, we accumulate changes inside the form component and send them “up” only on a specific event, such as when the submit button is clicked and after a successful validation.

Standard v-model problem

The standard behavior of v-model on form components in Vue is two-way data binding. As soon as the user enters a character into an input field, that change is immediately reflected in a variable bound via v-model in the parent component. This is convenient in many cases, but what if we need to perform some logic before the updated data gets to the parent?

The most common example is validation. We want to verify that the data entered is correct (e.g., that email really looks like email and age is a positive number) and only if all checks pass, pass the data on. Another example is data transformation: perhaps we need to case-code strings or format a number before sending it.

With a native v-model, such intermediate processing becomes inconvenient, as the parent receives “raw” and potentially invalid data with each change.

Solution: The “Pending v-model” pattern

The idea is simple:

  • A form component receives data via v-model (or modelValue props and update:modelValue event).
  • Inside the component, we create a local copy of that data (for example, using ref).
  • In our example, I make a copy of the data in the simplest way possible, via JSON.parse. Keep in mind that JSON.stringify/parse has limitations (loses Date, Function, undefined, etc). For complex cases, consider structuredClone (in modern environments) or libraries like lodash/cloneDeep.
  • All form fields (<input>, <select>, etc) are linked to this local copy, not to the v-model directly.

When the user initiates the form submission (e.g. clicks on the “Submit” button):

  • We perform all the necessary logic: validation, data transformation, etc. using the local copy.
  • If all validations are successful, we update the original v-model (call update:modelValue), passing it the processed data from the local copy.
  • We also use watch to track changes to the input modelValue, so that we can update our local copy if the data changes externally (e.g., when loading data for editing).

This way, the parent component only gets updated data when the form is in a valid and ready to submit state.

Example implementation (Vue 3 + TypeScript)

Let's rewrite the original example in TypeScript and break it down.

The form component UserForm.vue:

<script setup lang="ts">
import { ref, watch } from 'vue'
import type { Ref } from 'vue'

interface UserFormData {
  name: string;
  email: string;
  age: number | null;
}

const modelValue = defineModel<UserFormData | null>()

function clone<T>(obj: T): T {
  if (obj === undefined || obj === null) {
    return JSON.parse(JSON.stringify({ name: "", email: "", age: null }));
  }
  return JSON.parse(JSON.stringify(obj))
}

const form: Ref<UserFormData> = ref(clone(modelValue.value))

watch(modelValue, (newValue) => {
  form.value = clone(newValue)
}, { deep: true }) // deep: true необходимо для отслеживания изменений внутри объекта

function handleSubmit() {
  // const isValid = validateForm(form.value);
  // if (!isValid) return;

  modelValue.value = clone(form.value)
  console.log('Form submitted and model updated:', modelValue.value);
}
</script>

<template>
  <form class="flex flex-col gap-3" @submit.prevent="handleSubmit">
    <label class="input input-bordered flex items-center gap-2">
      Name
      <input class="flex-grow" type="text" v-model="form.name" required />
    </label>
    <label class="input input-bordered flex items-center gap-2">
      Email
      <input class="flex-grow" type="email" v-model="form.email" required />
    </label>
    <label class="input input-bordered flex items-center gap-2">
      Age
      <input class="flex-grow" type="number" v-model="form.age" min="3" required />
    </label>
    <button type="submit" class="btn btn-primary">
      {{ modelValue ? 'Edit' : 'Create' }} User
    </button>
  </form>
</template>

<style scoped>
.input {
  display: flex;
  padding: 0.5rem;
  border: 1px solid #ccc;
  border-radius: 4px;
}
.input input {
  border: none;
  outline: none;
  background-color: transparent;
}
.btn {
  padding: 0.75rem 1.5rem;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}
.btn-primary {
  background-color: #007bff;
  color: white;
}
.flex { display: flex; }
.flex-col { flex-direction: column; }
.gap-3 { gap: 0.75rem; } /* 12px if base is 16px */
.items-center { align-items: center; }
.gap-2 { gap: 0.5rem; } /* 8px */
.flex-grow { flex-grow: 1; }
</style>

Example of use in a parent component:

<script setup lang="ts">
import { ref } from 'vue'
import UserForm from './UserForm.vue'
import type { Ref } from 'vue'

interface UserFormData {
  name: string;
  email: string;
  age: number | null;
}

const user: Ref<UserFormData | null> = ref(null)

// const user: Ref<UserFormData | null> = ref({ name: "John Doe", email: "john@example.com", age: 30 });

</script>

<template>
  <div>
    <h1>User Management</h1>
    <UserForm v-model="user" />

    <div class="mt-4">
      <h2>Current User Data (Parent Scope):</h2>
      <pre>{{ JSON.stringify(user, null, 2) }}</pre>
    </div>
  </div>
</template>

<style scoped>
.mt-4 { margin-top: 1rem; }
pre {
  background-color: #f5f5f5;
  padding: 1rem;
  border-radius: 4px;
  white-space: pre-wrap;
  word-wrap: break-word;
}
</style>

And notice, testing this component will be very easy.

Let's assume we have an environment set up for testing with Vitest. We will create a UserForm.spec.ts file next to our component.

// UserForm.spec.ts

import { describe, it, expect, beforeEach } from 'vitest'
import { mount, VueWrapper } from '@vue/test-utils'
import UserForm from './UserForm.vue'

interface UserFormData {
  name: string;
  email: string;
  age: number | null;
}

type UserFormWrapper = VueWrapper<InstanceType<typeof UserForm>>

describe('UserForm.vue', () => {
  let wrapper: UserFormWrapper;

  describe('Create Mode (modelValue is null)', () => {
    beforeEach(() => {
      wrapper = mount(UserForm, {
        props: {
          'onUpdate:modelValue': (e) => wrapper.setProps({ modelValue: e })
        }
      }) as UserFormWrapper;
    });

    it('initialise form with empty values', () => {
      expect(wrapper.vm.form).toEqual({
        name: "",
        email: "",
        age: null
      });
      expect((wrapper.find('input[type="text"]').element as HTMLInputElement).value).toBe('');
      expect((wrapper.find('input[type="email"]').element as HTMLInputElement).value).toBe('');
      expect((wrapper.find('input[type="number"]').element as HTMLInputElement).value).toBe('');
    });

    it('updates local state after form updates', async () => {
      const nameInput = wrapper.find('input[type="text"]');
      const emailInput = wrapper.find('input[type="email"]');
      const ageInput = wrapper.find('input[type="number"]');

      await nameInput.setValue('Test User');
      await emailInput.setValue('test@example.com');
      await ageInput.setValue(25);

      expect(wrapper.vm.form).toEqual({
        name: "Test User",
        email: "test@example.com",
        age: 25
      });
    });

    it('Do not emits update:modelValue after user input', async () => {
      await wrapper.find('input[type="text"]').setValue('Testing');
      expect(wrapper.emitted('update:modelValue')).toBeUndefined();
    });

    it('emits update:modelValue with form data after (submit)', async () => {
      await wrapper.find('input[type="text"]').setValue('New User');
      await wrapper.find('input[type="email"]').setValue('new@example.com');
      await wrapper.find('input[type="number"]').setValue(30);

      await wrapper.find('form').trigger('submit.prevent');

      expect(wrapper.emitted('update:modelValue')).toBeTruthy();
      expect(wrapper.emitted('update:modelValue')).toHaveLength(1);
      expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([{
        name: "New User",
        email: "new@example.com",
        age: 30
      }]);
    });

     it('Shows button "Create User"', () => {
       expect(wrapper.find('button[type="submit"]').text()).toContain('Create User');
     });
  });

  describe('Edit Mode (modelValue is provided)', () => {
    const initialData: UserFormData = {
      name: "Initial Name",
      email: "initial@example.com",
      age: 42
    };

    beforeEach(() => {
      wrapper = mount(UserForm, {
        props: {
          modelValue: initialData,
          'onUpdate:modelValue': (e) => wrapper.setProps({ modelValue: e })
        }
      }) as UserFormWrapper;
    });

    it('initialise form with data from modelValue', () => {
      expect(wrapper.vm.form).toEqual(initialData);
      expect(wrapper.vm.form).not.toBe(initialData);

      expect((wrapper.find('input[type="text"]').element as HTMLInputElement).value).toBe(initialData.name);
      expect((wrapper.find('input[type="email"]').element as HTMLInputElement).value).toBe(initialData.email);
      expect((wrapper.find('input[type="number"]').element as HTMLInputElement).value).toBe(initialData.age?.toString());
    });

    it('updates local state after change values', async () => {
      await wrapper.find('input[type="text"]').setValue('Updated Name');
      expect(wrapper.vm.form.name).toBe('Updated Name');
      expect(wrapper.props('modelValue')).toEqual(initialData);
    });

     it('emits update:modelValue with updated values after sends data', async () => {
       const updatedName = 'Updated Name';
       await wrapper.find('input[type="text"]').setValue(updatedName);

       await wrapper.find('form').trigger('submit.prevent');

       expect(wrapper.emitted('update:modelValue')).toBeTruthy();
       expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([{
         ...initialData,
         name: updatedName
       }]);
     });

    it('updates local form, if modelValue changes from parent', async () => {
      const newData: UserFormData = {
        name: "External Update",
        email: "external@example.com",
        age: 99
      };

      await wrapper.setProps({ modelValue: newData });

      expect(wrapper.vm.form).toEqual(newData);
      expect((wrapper.find('input[type="text"]').element as HTMLInputElement).value).toBe(newData.name);
      expect((wrapper.find('input[type="email"]').element as HTMLInputElement).value).toBe(newData.email);
      expect((wrapper.find('input[type="number"]').element as HTMLInputElement).value).toBe(newData.age?.toString());
    });

    it('displays button "Edit User"', () => {
       expect(wrapper.find('button[type="submit"]').text()).toContain('Edit User');
     });
  });
});

Advantages of this approach

  • Controlled updates: The parent component receives data only when the form has been validated and is ready to be submitted. This prevents invalid or intermediate states from being processed.
  • Logic encapsulation: All logic related to the form state, including validation and possible data transformations, is centered inside the form component. The parent component remains clean.
  • Simplicity and focus: The form component is only concerned with its direct business of displaying fields and managing its internal state. It doesn't interact with the API directly, which makes it more overused. Sending data to the server is the task of the parent component or service layer.
  • Improved testability: It is easy to test such a component in isolation (unit tests). You can test validation logic and correctness of v-model updates without having to poke API requests or complex environment of the parent component.

Disadvantages and considerations

  • Additional code (Boilerplate): You need to create a local copy (ref), use watch to synchronize with modelValue and a handler function to update the v-model. This is a bit more code than using v-model directly.
  • Cloning: The JSON.parse(JSON.stringify(obj)) method used is simple, but can be inefficient for very large and complex objects. It also has limitations (doesn't copy functions, Date converts to strings, undefined replaces with null in arrays or skip in objects). In modern browsers and Node.js, you can use structuredClone(), which handles different data types better. To support older environments or for more flexibility, you can use libraries like lodash/cloneDeep.
  • State synchronization: You need to be careful about synchronization between modelValue and the local copy of form, especially during initialization and external updates. watch with { deep: true } solves this problem for most cases.

Conclusion

The Deferred v-model pattern is a great tool for creating robust and well-encapsulated form components in Vue 3, especially when validation or pre-processing of data is required before passing it to the parent component. While it adds a bit more code compared to a straight v-model, the benefits in the form of control, encapsulation, and testability often outweigh this small drawback. I highly recommend trying it in situations where the standard v-model behavior isn't quite right.

Popular Posts

My most popular posts

Maximum productivity on remote job
Business

Maximum productivity on remote job

I started my own business and intentionally did my best to work from anywhere in the world. Sometimes I sit with my office with a large 27-inch monitor in my apartment in Cheboksary. Sometimes I’m in the office or in some cafe in another city.

Hello! I am Sergey Emelyanov and I am hardworker
Business PHP

Hello! I am Sergey Emelyanov and I am hardworker

I am a programmer. I am an entrepreneur in my heart. I started making money from the age of 11, in the harsh 90s, handing over glassware to a local store and exchanging it for sweets. I earned so much that was enough for various snacks.

Hire Professional CRM developer for $25 per hour

I will make time for your project. Knowledge of Vtiger CRM, SuiteCRM, Laravel, Vue.js, Wordpress. I offer cooperation options that will help you take advantage of external experience, optimize costs and reduce risks. Full transparency of all stages of work and accounting for time costs. Pay only development working hours after accepting the task. Accept PayPal and Payoneer payment systems. How to hire professional developer? Just fill in the form

Telegram
@sergeyem
Telephone
+4915211100235
Email