By Samuel Oloruntoba, Bradley Kouchi and Manikandan Kurup

The v-model directive is one of the built-in directives in Vue.js. It provides two-way data binding between application state and form inputs by combining property binding and event handling into a single syntax.
Without v-model, you would wire each control by hand, binding :value (or the correct attribute for that control) and listening for input or change events to copy the new value back into your state. That pattern works, but it becomes repetitive across large forms. The tutorial on using v-model for two-way binding describes v-model as syntactic sugar that collapses those bindings into one directive.
With two-way data binding, changes made in form inputs automatically update application state, and updates to application state are automatically reflected in the UI without requiring manual DOM (Document Object Model) manipulation. Vue still keeps a clear data flow: the parent owns the source of truth, and the child notifies the parent when a value should change rather than mutating props directly.
In this article, you’ll explore how v-model works on native elements and custom Vue 3 components, how the Vue 2 contract differs for migration, and several implementation approaches, including defineModel(), manual modelValue wiring, computed setters, and watcher-based local state. You’ll also learn named v-model bindings, custom modifiers, how to build a reusable validated input, and how to apply the same pattern to contenteditable editors.
Key Takeaways:
v-model simplifies two-way data binding in Vue by combining prop binding and event handling into a single directive. It keeps form inputs and application state synchronized automatically.value + input, while checkboxes use checked + change.v-model contract to modelValue and update:modelValue. Vue 2 instead used value and input.defineModel() macro introduced in Vue 3.4 provides the cleanest way to add v-model support to custom components. It automatically handles the prop and emit wiring behind the scenes.v-model implementation patterns, including manual props/emits, computed setters, and watcher-based synchronization. The best approach depends on the component’s complexity and Vue version.v-model bindings like v-model:first-name, making it possible to manage multiple two-way bindings on the same component. This replaces the older Vue 2 model option pattern.v-model behavior manually for contenteditable elements by syncing DOM content with component state using refs, events, and watchers. This approach is commonly used in WYSIWYG editors and rich text components.To follow along with this article, you should have:
The examples in this tutorial use the Vue 3 Composition API and <script setup> syntax. Some sections also reference Vue 2 behavior to help explain migration differences and older implementation patterns.
v-model Works InternallyFrom our knowledge of HTML, we know that input, select, and textarea are the main ways we feed data to our application. On these native elements, v-model is not magic; it is a compile-time shortcut. The Vue forms guide documents which DOM property and which event Vue pairs for each control type.
Depending on the element, Vue decides how to listen for and handle the data:
| Element type | Property | Event |
|---|---|---|
<input> (text and most types), <textarea> |
value |
input |
<input type="checkbox">, <input type="radio"> |
checked |
change |
<select> |
value |
change |
For a text <input>, you might use v-model like this:
<input v-model="email" />
The template compiler expands that to something equivalent to:
<input :value="email" @input="email = $event.target.value" />
Vue applies the same idea to textarea, select, and the appropriate input types. For radio buttons and checkboxes, it binds checked and listens for change instead of using value and input.
When you bind multiple checkboxes to the same array or Set, or use a <select multiple>, v-model collects the selected values into an array automatically, as described in the checkbox binding section of our v-model tutorial and the select section of the Vue forms guide.
One important default: v-model ignores the initial value, checked, or selected attributes on form elements in the markup. Vue treats your JavaScript state (for example, a ref initialized in <script setup>) as the source of truth. Declare the starting value in your script, not only in HTML.
The same contract extends to custom components. To support v-model, a component must accept a prop that holds the current value and emit an event when that value should update in the parent. On components, Vue does not use value / input by default in Vue 3. It uses modelValue and update:modelValue, as shown in the next section.
v-model ContractsVue 2 and Vue 3 use different default prop-and-event contracts for v-model.
| Vue Version | Prop | Event |
|---|---|---|
| Vue 2 | value |
input |
| Vue 3 | modelValue |
update:modelValue |
On components, Vue 3 compiles v-model to a modelValue prop and an update:modelValue event. That is the default contract for custom inputs.
Vue 2 used a value prop and an input event instead. If you are migrating an older project, this naming difference is one of the most important changes to understand. The Vue 3 migration guide for v-model walks through the full list of breaking changes.
When you write <MyInput v-model="email" /> on a component in Vue 3, the compiler expands it to a prop binding and an update listener, similar to native elements:
<MyInput
:model-value="email"
@update:model-value="newValue => email = newValue"
/>
(Your build may use camelCase modelValue in script; both forms refer to the same contract.)
v-model to Custom ComponentsTo let a custom component support v-model, the child must accept the bound value as a prop and notify the parent when that value should change. In Vue 3, the default prop is modelValue and the event is update:modelValue. The child should treat modelValue as read-only and emit update:modelValue with the new value. Never assign directly to the prop, so the parent remains the single source of truth, as described in the props and events section of our component communication guide.
defineModel() (Vue 3.4+)The simplest approach is the defineModel() macro in <script setup>. It is a convenience macro: the compiler expands it to a modelValue prop synced with a local ref and an update:modelValue emit when that ref changes, as documented under Under the Hood in the component v-model guide.
<template>
<input v-model="model" />
</template>
<script setup>
const model = defineModel()
</script>
Use the component like this:
<BasicInput v-model="email" />
The value returned by defineModel() is a ref:
.value stays in sync with whatever the parent passed to v-model.model.value, Vue emits update:modelValue so the parent state updates as well.That is why nesting v-model on a native control (as above) is the common pattern for wrapper inputs: you get the same parent API (v-model on your component) while delegating to a real <input> underneath.
You can pass prop options to defineModel(), such as required: true or a default value:
const model = defineModel({ required: true })
// or
const model = defineModel({ default: '' })
Be careful with default when the parent does not pass v-model. The official defineModel docs warn that the parent ref can stay undefined while the child initializes to a default (for example, 1), which desynchronizes parent and child. Document that edge case, or avoid conflicting defaults, when the parent might omit the binding.
If you cannot use defineModel() (for example, on Vue 3.3 or lower), declare the prop and emit explicitly. This is the pattern the Vue docs show for pre-3.4 usage:
<template>
<input
:value="modelValue"
@input="$emit('update:modelValue', $event.target.value)"
/>
</template>
<script setup>
defineProps(['modelValue'])
defineEmits(['update:modelValue'])
</script>
The parent’s <BasicInput v-model="email" /> is compiled to the same :modelValue / @update:modelValue wiring shown in the component v-model guide. For a walkthrough of the underlying prop-and-emit pattern on custom components, see How To Add Two-Way Data Binding to Custom Components in Vue.js.
In Vue 2, the equivalent implementation used a value prop and emitted an input event instead:
<template>
<input
:value="value"
@input="$emit('input', $event.target.value)"
/>
</template>
<script>
export default {
props: ['value']
}
</script>
The child emits the input event because that is the event Vue 2 listens for when using v-model on custom components. Emitting a different event name would require additional configuration through the Vue 2 model option, covered later in this article.
When porting such a component to Vue 3, rename the prop to modelValue, change the emit to update:modelValue, and update parent templates, or adopt defineModel() so the compiler handles the wiring for you.
v-model Implementation StrategiesVue supports several patterns for implementing v-model behavior in custom components. All of them honor the same parent-facing contract (modelValue / update:modelValue by default, or a named argument pair). The best choice depends on your Vue version, component complexity, state synchronization requirements, and whether you need compatibility with older projects.
The component v-model guide documents defineModel(), manual props and emits, writable computed properties, and modifier handling. This section summarizes when each approach fits your component.
v-model Implementation Patterns| Approach | Best For | Advantages | Tradeoffs |
|---|---|---|---|
defineModel() |
Modern Vue 3.4+ applications | Minimal boilerplate, easiest to read, officially recommended | Requires Vue 3.4 or later |
Manual modelValue + update:modelValue |
Vue 3.0+ compatibility | Explicit and flexible | More repetitive boilerplate |
| Computed getter/setter | Wrapping or transforming values before emitting | Centralizes transformation logic | Slightly harder to understand for beginners |
| Local refs with watchers | Complex editors or async synchronization | Useful when local state temporarily diverges from parent state | More moving parts and synchronization logic |
For most new Vue 3 applications on Vue 3.4+, defineModel() is the preferred approach because it removes repetitive prop and emit declarations while still following Vue’s standard v-model contract.
A common alternative pattern is using a computed property with both a getter and setter. The Vue docs and our guide to custom component v-model describe this as another way to implement v-model in a child: the getter returns modelValue, and the setter emits update:modelValue. This approach is especially useful when the child component needs to transform or validate data before emitting updates: trimming strings, coercing numbers, or normalizing user input on the way out.
<template>
<input v-model="value" />
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])
const value = computed({
get: () => props.modelValue,
set: (newValue) => {
emit('update:modelValue', newValue.trim())
}
})
</script>
In this example, the setter trims whitespace before syncing the value back to the parent component. You could apply the same idea for other rules (uppercasing, stripping invalid characters, or clamping a numeric range) without changing how the parent uses v-model.
Some components, such as rich text editors, debounced search fields, or form builders, may need temporary local state before updating the parent value. The parent should not update on every keystroke or DOM mutation in those cases; the child holds a working copy and commits changes when appropriate.
When that applies, you can synchronize a local ref with the parent using watch():
<script setup>
import { ref, watch } from 'vue'
const props = defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])
const localValue = ref(props.modelValue)
watch(
() => props.modelValue,
(newValue) => {
localValue.value = newValue
}
)
watch(localValue, (newValue) => {
if (newValue !== props.modelValue) {
emit('update:modelValue', newValue)
}
})
</script>
This pattern is more useful for advanced components where updates may be delayed, transformed, or synchronized with third-party libraries. Use one watcher to pull parent changes into localValue, and another (or a debounced handler) to push child changes back through emit('update:modelValue', ...), so you do not create infinite update loops.
model Option (Legacy Projects)In Vue 2, components could customize v-model behavior using the model option. This allowed you to change both the prop name and the event name used by v-model for the entire component, with only one custom pairing per component.
For example, a checkbox component might prefer checked and change instead of the default value and input pair:
<template>
<input
type="checkbox"
:checked="checked"
@change="$emit('change', $event.target.checked)"
/>
</template>
<script>
export default {
model: {
prop: 'checked',
event: 'change'
},
props: {
checked: Boolean
}
}
</script>
Parent usage:
<CustomCheckbox v-model="isEnabled" />
In this example, Vue automatically maps v-model to the checked prop and listens for the change event because of the model configuration.
Vue 3 replaced this pattern with named v-model arguments such as v-model:checked, which are easier to read in templates and support multiple two-way bindings on one component without a separate model config object.
v-model and Multiple BindingsVue 2’s model option (prop / event customization) does not exist in Vue 3. Instead, you use an argument on v-model, as described in v-model arguments: v-model:propName syncs to prop propName and listens for update:propName.
This naming mirrors Vue’s convention for component events: an update: prefix plus the prop name. It also avoids collisions with other props on the same component, which is useful when a field is literally called value or when you need a checkbox-style checked binding separate from a text modelValue.
With defineModel(), pass the argument name as the first parameter:
<template>
<input type="text" v-model="hidden" />
</template>
<script setup>
const hidden = defineModel('hidden')
</script>
Parent usage:
<BasicInput v-model:hidden="email" />
That compiles to binding :hidden and listening for @update:hidden, which avoids clashing with other props or the default modelValue binding. In templates, kebab-case arguments such as v-model:first-name map to camelCase props like firstName in script, following Vue’s usual prop casing rules.
You can attach several v-model bindings to one component, as shown in Multiple v-model bindings:
<UserName
v-model:first-name="first"
v-model:last-name="last"
/>
<template>
<input type="text" v-model="firstName" />
<input type="text" v-model="lastName" />
</template>
<script setup>
const firstName = defineModel('firstName')
const lastName = defineModel('lastName')
</script>
Optional prop options go in the second argument: defineModel('title', { required: true }).
v-modelNative inputs support built-in modifiers such as .lazy, .number, and .trim (see modifiers in the forms guide). Custom components can support modifiers too: on the parent, v-model.capitalize="text" exposes a capitalize flag to the child. With defineModel(), destructure the return value (const [model, modifiers] = defineModel()) or use get / set options on defineModel() to transform values. Named arguments use a matching {arg}Modifiers prop (for example, titleModifiers for v-model:title.capitalize). See Handling v-model modifiers in the Vue docs for full examples.
The following example combines several concepts covered in this article into a reusable Vue 3 form component. It uses defineModel() for the field value, regular defineProps for presentation and validation rules, and a computed property for derived error text, so the parent keeps a simple v-model API while the child owns labels and validation messaging.
This component:
label propv-model with defineModel()<template>
<label class="input-wrapper">
<span>{{ label }}</span>
<input
v-model="model"
:placeholder="placeholder"
:class="{ invalid: errorMessage }"
/>
<small v-if="errorMessage">
{{ errorMessage }}
</small>
</label>
</template>
<script setup>
import { computed } from 'vue'
const model = defineModel()
const props = defineProps({
label: {
type: String,
required: true
},
placeholder: {
type: String,
default: ''
},
minLength: {
type: Number,
default: 0
}
})
const errorMessage = computed(() => {
if (
props.minLength &&
(model.value ?? '').length < props.minLength
) {
return `Input must be at least ${props.minLength} characters long.`
}
return ''
})
</script>
You can use the component in a parent component like this:
<template>
<BaseInput
v-model="username"
label="Username"
placeholder="Enter your username"
:minLength="5"
/>
<p>Current value: {{ username }}</p>
</template>
<script setup>
import { ref } from 'vue'
import BaseInput from './BaseInput.vue'
const username = ref('')
</script>
This pattern works well for reusable form systems because the component stays compatible with standard Vue v-model behavior while still supporting validation, formatting, and additional UI features. The validation here runs in the child for display only; for form-wide rules you might combine this with a dedicated validation library or the browser’s constraint validation API, but the v-model contract on BaseInput stays the same for parents.
v-model on contenteditableA contenteditable element is a div or similar element that can be configured to work as an input. Unlike <input> and <textarea>, it is not a form control, so Vue’s built-in v-model expansion does not apply to it automatically.
We define contenteditable elements by adding the contenteditable attribute to the element:
<div
class="editor"
contenteditable="true"
ref="editor"
></div>
You’ll use contenteditable elements for WYSIWYG editors because they are easier to work with and are widely supported across modern browsers. MDN notes that the attribute accepts true, false, or the string "plaintext-only" for simpler editing without rich formatting.
Vue does not provide native v-model support for contenteditable elements in the same way it does for form inputs. You implement the same contract manually: read the element’s content on input, write parent state into the DOM when the model changes, and guard against feedback loops when parent and child both update the same node.
Example with defineModel() and a template ref:
<template>
<div
ref="editorRef"
class="editor"
contenteditable="true"
@input="onInput"
></div>
</template>
<script setup>
import { ref, watch, onMounted } from 'vue'
const model = defineModel()
const editorRef = ref(null)
function onInput() {
const el = editorRef.value
if (!el) return
model.value = el.innerText
}
onMounted(() => {
const el = editorRef.value
if (el && model.value != null) {
el.innerText = model.value
}
})
watch(model, (val) => {
const el = editorRef.value
if (!el || el.innerText === val) return
el.innerText = val ?? ''
})
</script>
The parent uses <ContentEditor v-model="content" /> like any other custom input.
A few practical details:
innerText vs innerHTML: innerText stores plain text and is usually safer for simple editors. innerHTML preserves markup but requires sanitization if the content can come from users, to avoid XSS.watch compares el.innerText === val before writing so a child update does not immediately re-trigger a redundant DOM write.onMounted seeds the editor when the parent already has a value, matching the Vue forms guide rule that JavaScript state is the source of truth.For production WYSIWYG tools, you will often wrap a library (TipTap, Quill, and similar) and still expose v-model on your wrapper with the same defineModel() or update:modelValue pattern.
When working with v-model in custom components, a few implementation mistakes appear frequently, especially when switching between Vue 2 and Vue 3 patterns. Understanding these issues will help you avoid synchronization bugs and confusing component behavior.
One of the most common mistakes is modifying a prop directly inside a child component.
For example, this is incorrect:
<script setup>
const props = defineProps(['modelValue'])
function updateValue(newValue) {
props.modelValue = newValue
}
</script>
Props in Vue are read-only. Attempting to mutate them directly will generate warnings because the parent component owns the source of truth.
Instead, emit an update event or use defineModel():
<script setup>
const emit = defineEmits(['update:modelValue'])
function updateValue(newValue) {
emit('update:modelValue', newValue)
}
</script>
Or, with defineModel():
const model = defineModel()
model.value = 'Updated value'
This keeps state changes flowing in the correct direction while still supporting two-way binding.
Another common migration mistake is continuing to emit the Vue 2 input event inside Vue 3 components.
This Vue 2 pattern will not work correctly with default Vue 3 v-model behavior:
<input
:value="modelValue"
@input="$emit('input', $event.target.value)"
/>
In Vue 3, the correct event name is update:modelValue:
<input
:value="modelValue"
@input="$emit('update:modelValue', $event.target.value)"
/>
Vue 3 specifically looks for the update:modelValue event when using v-model on custom components.
In Vue 3, components should explicitly declare the events they emit.
For example:
defineEmits(['update:modelValue'])
While some examples may still work without defineEmits(), omitting it can lead to:
Declaring emits also makes components easier to understand and maintain because other developers can immediately see which events the component supports.
Projects migrating from Vue 2 to Vue 3 often accidentally combine patterns from both versions.
For example:
value prop with update:modelValuemodelValue while still emitting inputmodel option with Vue 3 named v-model argumentsThese mismatched contracts usually cause v-model synchronization to stop working correctly.
To avoid confusion:
value + input consistently in Vue 2modelValue + update:modelValue consistently in Vue 3defineModel() in modern Vue 3.4+ applicationsWatchers are useful for advanced cases like debounced inputs, rich text editors, or third-party integrations. However, they are often unnecessary for standard form components.
This pattern is usually more complex than needed for a simple input:
watch(localValue, (newValue) => {
emit('update:modelValue', newValue)
})
For basic inputs, defineModel() or a computed setter is typically simpler and easier to maintain.
Use watchers only when your component genuinely requires temporary local state or asynchronous synchronization behavior.
When you pass a default option to defineModel(), it applies on the child side only. The parent’s bound ref does not pick up that default automatically if the parent omits v-model or leaves the ref uninitialized. The official defineModel docs call this out: a child can show one value while the parent still holds undefined, which is easy to miss during testing.
For example, you might define a model with an empty string fallback:
const model = defineModel({
default: ''
})
If the parent uses <MyInput /> without v-model, or binds to a ref that starts as undefined, the input can render as an empty field while parentRef stays undefined. The UI looks correct, but any logic that reads the parent ref will not see the child’s default.
In reusable component libraries, you can reduce that drift by:
const email = ref('') before passing v-model)Taking a moment to align defaults on both sides prevents subtle synchronization bugs, especially in larger forms where many wrapped inputs share the same pattern.
The following answers summarize common questions about custom component v-model. They align with the Vue 3 component v-model documentation and the behavior described in the sections above.
v-model in a custom Vue component?Your component must accept a prop that holds the bound value and emit an update when that value should change. In Vue 2, the default prop is value and the event is input; in Vue 3, they are modelValue and update:modelValue. The parent can then write <MyInput v-model="email" />, and Vue expands that into the matching :prop binding and @update:prop listener. This follows the same pattern as native inputs, but with the component-specific prop and event names. In Vue 3.4+, you can implement this contract with defineModel() instead of declaring the prop and emit by hand; the macro still compiles down to the same modelValue / update:modelValue interface.
v-model work differently in Vue 2 versus Vue 3?In Vue 2, v-model on a component binds to a prop named value and listens for an input event by default. In Vue 3, it binds to modelValue and listens for update:modelValue, which lines up better with how other props and events are named. Customization also changed: Vue 2 used a model option on the component to pick a different prop and event; Vue 3 uses named v-model arguments such as v-model:title, which map to title and update:title without extra configuration objects.
defineModel() in Vue 3 and when should I use it?defineModel() is a compiler macro introduced in Vue 3.4 for use in <script setup>. It declares the modelValue prop and update:modelValue emit for you and returns a ref that stays in sync with the parent’s bound value. You should use it on Vue 3.4 and later when you want the shortest, clearest way to add default v-model support, especially for wrapper inputs, without repeating defineProps and defineEmits in every component. You can still pass standard prop options (required, default, and others) as the argument to defineModel(). For named bindings, pass the argument name first: defineModel('title').
v-model bindings?Yes. A single component instance can expose more than one two-way binding. In the parent, use v-model:propName="value" for each field; for example, v-model:first-name and v-model:last-name on a name form. In the child, each argument corresponds to its own prop and update:propName event; with defineModel(), call defineModel('firstName') and defineModel('lastName') (or the manual equivalent) so each input syncs with the correct parent state.
v-model?A vnode (virtual node) is Vue’s lightweight description of a DOM element or component in its virtual DOM tree. When you use v-model, the compiler still turns your template into vnode trees with the right props and event listeners attached. You do not implement v-model by creating vnodes yourself, but knowing they exist helps explain why prop and event bindings persist correctly across re-renders without you touching the real DOM.
v-model directive in Vue?v-model is a built-in directive that wires up two-way binding between application state and what the user sees. On native elements such as <input>, it is shorthand for binding :value (or the appropriate attribute for that control, such as checked on checkboxes) and listening to the input or change event that carries the new value. See our tutorial on v-model two-way binding and the Vue 3 forms guide for the full element matrix. On custom components, it is shorthand for binding the model prop (for example, modelValue) and listening for the matching update event, so parent and child stay aligned without manual event handlers in every template. Modifiers such as .trim and .number work on native inputs; components can read custom modifiers through defineModel() or modelModifiers.
v-model correctly?Vue expects data to flow down from parent to child through props. If a child assigns to a prop directly (for example, props.modelValue = 'new text'), Vue warns you at runtime and the parent’s source of truth does not update reliably. The supported pattern is to treat the prop as read-only in the child, then emit update:modelValue (or a named variant) so the parent can change the bound ref or data. That is exactly what a correct v-model implementation does, whether you use defineModel() or explicit emits.
v-model support to a custom checkbox component?Checkboxes do not use the default value / modelValue pairing in the same way as text inputs; they reflect selection with checked and typically fire change. In Vue 2, set the component’s model option to prop: 'checked' and event: 'change'. In Vue 3, use a named binding such as v-model:checked="isOn" in the parent and defineModel('checked') in the child (or declare a checked prop and update:checked emit manually), and bind the native checkbox’s checked attribute to that model while emitting updates on change.
You explored how v-model expands on native elements and on custom Vue 3 components, where the default contract is modelValue and update:modelValue (with Vue 2’s value / input and model option noted for migration). You can implement that contract with defineModel(), manual props and emits, computed setters, or local state with watchers; extend it with named v-model bindings, custom modifiers, and manual syncing for contenteditable editors, as in the reusable BaseInput example. In every case, the parent owns the data, the child emits updates, and a single v-model in the template keeps the API simple.
For further reading, consult the official documentation for component v-model, our tutorial on v-model for native inputs, How To Add Two-Way Data Binding to Custom Components in Vue.js, and the Vue 3 v-model migration notes if you maintain Vue 2 code. You can also check out the Vue.js topic page for more exercises and projects.
Thanks for learning with the DigitalOcean Community. Check out our offerings for compute, storage, networking, and managed databases.
Former Technical Editor at DigitalOcean. Expertise in areas including Vue.js, CSS, React, and more.
With over 6 years of experience in tech publishing, Mani has edited and published more than 75 books covering a wide range of data science topics. Known for his strong attention to detail and technical knowledge, Mani specializes in creating clear, concise, and easy-to-understand content tailored for developers.
This textbox defaults to using Markdown to format your answer.
You can type !ref in this text area to quickly search our full set of tutorials, documentation & marketplace offerings and insert the link!
For using v-model on Components with the Composition API see: https://vuejs.org/guide/components/events.html#usage-with-v-model
WARNING: out of date information. Please check the doumentation at
Get paid to write technical tutorials and select a tech-focused charity to receive a matching donation.
Full documentation for every DigitalOcean product.
The Wave has everything you need to know about building a business, from raising funding to marketing your product.
Stay up to date by signing up for DigitalOcean’s Infrastructure as a Newsletter.
New accounts only. By submitting your email you agree to our Privacy Policy
Scale up as you grow — whether you're running one virtual machine or ten thousand.
From GPU-powered inference and Kubernetes to managed databases and storage, get everything you need to build, scale, and deploy intelligent applications.