Skip to content

Fix: Vue v-model Not Working on Custom Components — Prop Not Syncing

FixDevs · (Updated: )

Part of:  React & Frontend Errors

Quick Answer

How to fix Vue v-model on custom components — defineModel, modelValue/update:modelValue pattern, multiple v-model bindings, v-model modifiers, and Vue 2 vs Vue 3 differences.

The Problem

v-model on a custom Vue component does not update the parent’s value:

<!-- Parent -->
<MyInput v-model="username" />
<p>Username: {{ username }}</p>  <!-- Never updates when typing -->

Or the value binds in one direction but changes are not reflected back:

<!-- Child receives the value but changes don't propagate -->
<input :value="modelValue" @input="$emit('input', $event.target.value)" />
<!-- Vue 3 uses 'update:modelValue' not 'input' -->

Or v-model on a component causes a Vue warning:

[Vue warn]: Component emitted event "input" but it is not declared in the emits option.
[Vue warn]: Extraneous non-emits event listeners (update:modelValue) were passed to component
but could not be automatically inherited because component renders fragment or text.

Or multiple v-model bindings on the same component do not work:

<UserForm v-model:firstName="first" v-model:lastName="last" />

Why This Happens

v-model is syntactic sugar that expands to a prop + event binding. The critical detail that catches developers is that the prop name and event name changed between Vue 2 and Vue 3. Code that works in one version silently fails in the other, often without a clear error — just a one-way binding instead of two-way.

In Vue 2, v-model expands to :value + @input. In Vue 3, it expands to :modelValue + @update:modelValue. If you use the Vue 2 pattern in a Vue 3 project, the child component receives the value (because Vue still passes it via $attrs), but the parent never updates — because the parent is listening for update:modelValue while the child emits input. The data flows down but never back up.

This is further complicated by migration guides and tutorials that mix Vue 2 and Vue 3 syntax without clear version labels, and by component libraries that may not have fully migrated their v-model implementations.

Vue 2: v-model expands to :value prop + @input event

<MyInput v-model="username" />
<!-- Expands to: -->
<MyInput :value="username" @input="username = $event" />

Vue 3: v-model expands to :modelValue prop + @update:modelValue event

<MyInput v-model="username" />
<!-- Expands to: -->
<MyInput :modelValue="username" @update:modelValue="username = $event" />

Common mistakes:

  • Using Vue 2 pattern in Vue 3 — emitting 'input' instead of 'update:modelValue'
  • Using the wrong prop name — prop named value instead of modelValue in Vue 3
  • Not declaring emits — without defineEmits, Vue warns and may not propagate events correctly
  • Missing defineModel() — Vue 3.4+ has a simpler macro that handles everything automatically
  • Mutating prop directly — writing to props.modelValue directly instead of emitting an event

Diagnostic Timeline

Here is how an experienced Vue developer debugs a broken v-model on a custom component.

Minute 0 — Check the Vue version. This determines everything. Run vue --version or check package.json for the vue dependency version. If Vue 2, the component needs value prop + input emit. If Vue 3, it needs modelValue prop + update:modelValue emit (or defineModel() in 3.4+). Mixing these patterns is the number one cause of broken v-model.

Minute 1 — First instinct: “add the modelValue prop.” This is correct for Vue 3, but incomplete. Adding the prop only fixes the downward flow (parent to child). The child must also emit update:modelValue to push changes back up. If you add the prop but forget the emit declaration, data flows one way only. Check both: does the component declare a modelValue prop AND emit update:modelValue?

Minute 2 — Open Vue DevTools. Select the child component and check the Props panel. modelValue should appear with the current value from the parent. Now interact with the input. Switch to the Events panel. You should see update:modelValue fire with the new value each time you type. If the prop appears but no event fires, the child is not emitting. If the event fires but the parent does not update, the event name is wrong.

Minute 3 — Check the emit payload. A subtle bug: emitting $event instead of $event.target.value. If the child emits the raw DOM Event object instead of the string value, the parent receives [object InputEvent] as the new value. This shows up as the input field displaying “[object InputEvent]” after the first keystroke.

Minute 4 — Distinguish Vue 2 migration issues. If the project was migrated from Vue 2 to Vue 3 (or uses a compatibility build), search the codebase for $emit('input', — this is the Vue 2 pattern. In Vue 3, these must all be changed to $emit('update:modelValue',. Also search for props: ['value'] and change to props: ['modelValue']. The Vue migration helper (vue-migration-helper) can find these automatically.

Minute 5 — Check for defineModel availability. If using Vue 3.4+, defineModel() replaces the entire props/emits boilerplate with a single line. But if the project is on Vue 3.3 or earlier, defineModel() is not available and using it will cause a compile error, not a v-model error. Verify the Vue version supports it before refactoring.

Fix 1: Use defineModel (Vue 3.4+)

defineModel() is the simplest modern approach — it creates a reactive ref that automatically syncs with the parent:

<!-- MyInput.vue — using defineModel (Vue 3.4+) -->
<template>
  <input
    :value="model"
    @input="model = $event.target.value"
    class="my-input"
  />
</template>

<script setup lang="ts">
// defineModel() creates a two-way binding automatically
// No need for props/emits boilerplate
const model = defineModel<string>({ default: '' });

// model.value reads the current value
// Assigning to model.value emits 'update:modelValue' automatically
</script>
<!-- Parent usage -->
<script setup>
import { ref } from 'vue';
import MyInput from './MyInput.vue';

const username = ref('');
</script>

<template>
  <MyInput v-model="username" />
  <p>Username: {{ username }}</p>
</template>

Multiple defineModel() bindings:

<!-- UserForm.vue -->
<script setup lang="ts">
const firstName = defineModel<string>('firstName', { default: '' });
const lastName = defineModel<string>('lastName', { default: '' });
</script>

<template>
  <input :value="firstName" @input="firstName = $event.target.value" />
  <input :value="lastName" @input="lastName = $event.target.value" />
</template>
<!-- Parent -->
<UserForm v-model:firstName="first" v-model:lastName="last" />

Fix 2: Implement v-model Manually (Vue 3, Pre-3.4)

For Vue 3 without defineModel, implement the modelValue prop + update:modelValue emit pattern:

<!-- MyInput.vue — Vue 3 manual pattern -->
<template>
  <input
    :value="modelValue"
    @input="$emit('update:modelValue', $event.target.value)"
    class="my-input"
  />
</template>

<script setup lang="ts">
// Define the prop
defineProps<{
  modelValue: string;
}>();

// Declare the emit — required in Vue 3
const emit = defineEmits<{
  'update:modelValue': [value: string];
}>();
</script>

For a checkbox with boolean v-model:

<!-- MyCheckbox.vue -->
<template>
  <input
    type="checkbox"
    :checked="modelValue"
    @change="$emit('update:modelValue', $event.target.checked)"
  />
</template>

<script setup lang="ts">
defineProps<{ modelValue: boolean }>();
defineEmits<{ 'update:modelValue': [value: boolean] }>();
</script>

Using a computed setter (cleaner for complex components):

<template>
  <input v-model="inputValue" />
</template>

<script setup lang="ts">
import { computed } from 'vue';

const props = defineProps<{ modelValue: string }>();
const emit = defineEmits<{ 'update:modelValue': [value: string] }>();

// Computed with getter and setter — transparent two-way binding
const inputValue = computed({
  get: () => props.modelValue,
  set: (value) => emit('update:modelValue', value),
});
// inputValue.value reads props.modelValue
// Assigning to inputValue.value emits the update event
</script>

Fix 3: Fix Vue 2 Components (value + input)

Vue 2 uses :value and @input — or the model option to customize:

<!-- Vue 2 — default v-model pattern -->
<template>
  <input
    :value="value"
    @input="$emit('input', $event.target.value)"
  />
</template>

<script>
export default {
  props: ['value'],
};
</script>

Vue 2 model option — customize the prop and event:

<script>
export default {
  model: {
    prop: 'checked',    // Customize prop name (for checkboxes: avoid 'value')
    event: 'change',    // Customize event name
  },
  props: {
    checked: Boolean,
  },
};
</script>

Vue 2 .sync modifier (similar to multiple v-model in Vue 3):

<!-- Vue 2 .sync — for multiple two-way bindings -->
<UserForm :firstName.sync="first" :lastName.sync="last" />

<!-- Child emits: this.$emit('update:firstName', newValue) -->

Fix 4: Add v-model Modifiers

Vue 3 supports custom modifiers on v-model:

<!-- Parent — using custom modifier 'capitalize' -->
<MyInput v-model.capitalize="username" />
<!-- MyInput.vue — handle the modifier -->
<template>
  <input
    :value="modelValue"
    @input="handleInput($event.target.value)"
  />
</template>

<script setup lang="ts">
const props = defineProps<{
  modelValue: string;
  modelModifiers?: {
    capitalize?: boolean;  // Corresponds to v-model.capitalize
    trim?: boolean;
  };
}>();

const emit = defineEmits<{
  'update:modelValue': [value: string];
}>();

function handleInput(value: string) {
  let result = value;

  if (props.modelModifiers?.capitalize) {
    result = result.charAt(0).toUpperCase() + result.slice(1);
  }

  if (props.modelModifiers?.trim) {
    result = result.trim();
  }

  emit('update:modelValue', result);
}
</script>

With defineModel() (Vue 3.4+) — even simpler:

<script setup lang="ts">
const [model, modifiers] = defineModel<string>({
  set(value) {
    if (modifiers.capitalize) {
      return value.charAt(0).toUpperCase() + value.slice(1);
    }
    if (modifiers.trim) {
      return value.trim();
    }
    return value;
  },
});
</script>

Fix 5: v-model on a Wrapper Component (Forwarding)

When wrapping an <input> element in a component with extra functionality (label, error display), forward v-model correctly:

<!-- FormField.vue — wraps input with label and error -->
<template>
  <div class="form-field">
    <label :for="fieldId">{{ label }}</label>
    <input
      :id="fieldId"
      :value="modelValue"
      @input="$emit('update:modelValue', $event.target.value)"
      :class="{ error: !!errorMessage }"
      v-bind="$attrs"
    />
    <span v-if="errorMessage" class="error-msg">{{ errorMessage }}</span>
  </div>
</template>

<script setup lang="ts">
import { useId } from 'vue';

defineProps<{
  modelValue: string;
  label: string;
  errorMessage?: string;
}>();

defineEmits<{ 'update:modelValue': [value: string] }>();

const fieldId = useId();  // Unique ID for label/input association
</script>

inheritAttrs: false to prevent attribute duplication:

<script setup lang="ts">
// Prevent Vue from automatically applying attrs to the root element
// Use v-bind="$attrs" on the specific element that should receive them
defineOptions({ inheritAttrs: false });
</script>

Fix 6: Multiple v-model Bindings

Vue 3 supports multiple v-model bindings on a single component:

<!-- Parent -->
<DateRangePicker
  v-model:start="startDate"
  v-model:end="endDate"
/>
<!-- DateRangePicker.vue -->
<template>
  <div class="date-range">
    <input type="date" :value="start" @input="$emit('update:start', $event.target.value)" />
    <span>to</span>
    <input type="date" :value="end" @input="$emit('update:end', $event.target.value)" />
  </div>
</template>

<script setup lang="ts">
defineProps<{
  start: string;
  end: string;
}>();

defineEmits<{
  'update:start': [value: string];
  'update:end': [value: string];
}>();
</script>

With defineModel() — even cleaner:

<script setup lang="ts">
const start = defineModel<string>('start');
const end = defineModel<string>('end');
</script>

<template>
  <input type="date" v-model="start" />
  <span>to</span>
  <input type="date" v-model="end" />
</template>

Fix 7: Debug v-model Issues

Check the prop and emit in Vue DevTools:

  1. Open Vue DevTools and select the child component.
  2. Check PropsmodelValue should show the current value.
  3. Interact with the input.
  4. Check Eventsupdate:modelValue should appear with the new value.

If update:modelValue does not appear in events, the child is not emitting correctly.

Log the emit:

<script setup>
const emit = defineEmits(['update:modelValue']);

function handleInput(event) {
  const value = event.target.value;
  console.log('Emitting update:modelValue with:', value);
  emit('update:modelValue', value);
}
</script>

Common emit mistakes:

<!-- WRONG in Vue 3 — emitting 'input' not 'update:modelValue' -->
@input="$emit('input', $event.target.value)"

<!-- WRONG — emitting the Event object instead of the value -->
@input="$emit('update:modelValue', $event)"
<!-- Should be: $event.target.value -->

<!-- WRONG — calling emit in defineEmits instead of using the returned function -->
defineEmits(['update:modelValue'])('update:modelValue', value)  // Don't do this

Still Not Working?

Verify Vue versiondefineModel() requires Vue 3.4+. For earlier Vue 3 versions, use the manual modelValue + update:modelValue pattern.

v-model on a non-form elementv-model on a <div>, <span>, or custom element requires the component to handle the binding. v-model only works automatically on <input>, <textarea>, <select> for native elements.

$attrs includes onUpdate:modelValue — if your component has inheritAttrs: true (default) and no modelValue prop defined, the event listener may be applied to the root element via $attrs. Add the prop definition to prevent this.

Vuex/Pinia store with v-model — do not use v-model directly on a store state property (it mutates state directly, bypassing Pinia/Vuex). Use a computed setter:

// With Pinia
const store = useUserStore();
const username = computed({
  get: () => store.username,
  set: (value) => store.setUsername(value),
});
// Then use v-model="username" — goes through the action

Check for component fragments. In Vue 3, components can have multiple root elements (fragments). If your component has multiple root elements and does not declare modelValue as a prop, Vue cannot auto-inherit the onUpdate:modelValue listener via $attrs because it does not know which root element to attach it to. The fix is to explicitly declare the prop and emit, or use v-bind="$attrs" on the correct element.

Ensure defineEmits is called in <script setup>. If you use <script setup> but forget defineEmits, Vue still allows emitting events, but without the emit declaration, Vue Dev Tools may not track the events and TypeScript will not type-check the emit payloads. Always declare emits explicitly for reliable behavior.

Watch for debounced or throttled input handlers. If you wrap the emit in a debounce function (common for search inputs), the v-model update is delayed. The parent’s reactive value will not update until the debounce fires. This is not a bug, but it can look like v-model is broken during testing. Verify by removing the debounce temporarily.

For related Vue issues, see Fix: Vue Computed Property Not Updating, Fix: Vue Reactive Data Not Updating, Fix: Vue Composition API Reactivity Loss, and Fix: Vue Router Params Not Updating.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles