•5 min read

Vue Multiple v-models on One Component: The defineModel Traps

Last Tuesday I wired a date range filter into a client dashboard. Two fields, start and end, both bound with v-model on one component. The start date updated fine. The end date did absolutely nothing in either direction, and the console stayed completely clean. Fifteen minutes of staring later I found it: the child emitted update:enddate while the parent bound v-model:end-date. One dropped capital letter. No warning, no error, no hint. Just a binding going nowhere.

That is the thing about component v-model in Vue 3. When it works, it's the nicest API in the framework. When it doesn't, it fails quietly. Here's how multiple v-models actually fit together, plus the traps I've collected since defineModel became the way I write them.

A laptop on a desk with frontend code open in an editor, mid debugging session

v-model on a component is just sugar

On a native input, v-model binds value and listens for input. On a component, it does the same dance with different names. v-model="title" expands to a modelValue prop plus an update:modelValue event listener, a contract the official docs spell out in full. If you came from Vue 2, this is the part that bites: back then it was a value prop and an input event, and plenty of old components still carry those names around like a passport from another country.

You can always write the long form yourself and it behaves identically.

<script setup lang="ts">
const props = defineProps<{ modelValue: string }>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
</script>

Nothing wrong with that. It's just three moving parts you keep in sync by hand. Since Vue 3.4, defineModel does it for you and hands back a ref you can read and write directly.

Two v-models on one component

The real fun starts when a component needs more than one binding. A date range picker with a start and an end is the classic case. Name each model and they stay independent:

<script setup lang="ts">
const start = defineModel<string>('startDate', { required: true })
const end = defineModel<string>('endDate')
</script>

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

The parent binds each one by name:

<DateRange
  v-model:start-date="filters.from"
  v-model:end-date="filters.to"
/>

Each named model gets its own prop and its own update: event. Kebab-case in the template maps to camelCase in the script, so v-model:start-date finds the startDate model. That mapping is one-way, by the way. The template side is flexible about case. The child side is not, which brings me to last Tuesday.

The silent mismatch

Vue checks prop and event names by exact string match. update:endDate and update:enddate are different events, and defineModel('endDate') only listens for the first one. Emit the second and nothing happens. No warning. Vue Devtools will show the event firing, which is somehow worse, because it looks like the child did its job.

I've hit this twice now, both times in a component that had grown past its original single v-model. Someone renames a model in the parent, forgets the child, and one field goes dead while its neighbor works. My habit since: when a v-model doesn't update, the first thing I grep is the event name in the child, not the binding in the parent. It's always the child.

Mutating the prop feels like it works

The second trap is sneakier because it half-functions. Say a child receives modelValue and, instead of emitting, assigns straight to the prop. In development Vue warns you that props are readonly. But a native input inside that component still shows your keystrokes, because the DOM keeps its own local state. The parent never hears about any of it.

Then the parent re-renders for an unrelated reason and the input snaps back to the old value. Users report it as "the field ate my text", which is a fun bug to receive on a Friday.

The fix is boring and always the same: emit, don't assign. With defineModel you get this almost for free, since writing to the model ref emits the update for you. One caveat worth knowing: if the parent never binds a v-model at all, the model ref falls back to local state. Handy for prototypes, but it means a missing binding can hide from you in dev and only show up when two components disagree about who owns the value.

A related habit I've dropped: passing a whole object down and mutating its properties in the child, v-model="form" style. It works, because both sides hold the same reference. It also makes the data flow invisible, and I've already written once about how destructuring props quietly loses reactivity. Same disease, different symptom. One model per value keeps ownership obvious.

Modifiers come along for free

Custom modifiers survive the trip too. Destructure the second return value and use a set transformer to change the value on its way out:

const [title, modifiers] = defineModel<string>('title', {
  set(value) {
    return modifiers.uppercase ? value.toUpperCase() : value
  },
})

A parent can now write v-model:title.uppercase="name" and the child decides what that means. Same pattern as the built-in .trim and .lazy, just yours.

Since that Tuesday I keep the whole pattern in Snippet Ark as a snippet: child with two named models, parent binding both. It's eight lines and I still paste it more often than I'd like to admit. If you want the surrounding Vue pitfalls, I've also written about Suspense never showing its fallback and props that lose reactivity when destructured. Component communication is where most of my Vue bugs live, and v-model mismatches top that list.