Rather than three separate pieces for a two-way bound value, defineModel gives this common Vue component pattern a single line of syntax.
In my previous article on Vue Basics: Script Setup Sugar, we went through the five compiler macros that make <script setup> so pleasant to work with. defineModel got a section there, but it was sharing the page with four other macros. I also said outright that the v-model syntax itself was out of scope for that piece. This post is that missing piece.
If you’ve been writing Vue components for a while, you know the ritual. You want a custom input component that works with v-model, so you declare a modelValue prop, declare an update:modelValue emit, and then write a little computed property or a handler to glue the two together. That’s three moving parts for one simple idea. A value flows into your component, and it flows back out.
defineModel collapses all of that into a single line. It landed as a stable feature in Vue 3.4, and today I want to walk through how it works, what it compiles down to, how to handle multiple bindings and modifiers, and the one gotcha that will bite you if nobody warns you about it.
What Is defineModel?
defineModel is a compiler macro, the same family as defineProps and defineEmits. You call it inside <script setup>, and it hands you back a ref.
That ref is special. Reading it gives you the value the parent passed down. Writing to it tells the parent to update. You never touch emit directly.
Behind the scenes, the compiler turns that one line into a prop named modelValue and an emit named update:modelValue. When you do model.value = 'draft', the emit fires with that string as the payload. The parent’s v-model picks it up and updates its own state, which flows back down as the prop.
So there’s nothing new happening underneath. It’s the same prop and event pair Vue has always used for component v-model. The difference is that Vue writes it for you.
The Way We Used to Do It
Let’s look at a text input wrapper written the pre-3.4 way, because the difference is easier to see side by side.
BaseInput.vue
That works, and plenty of production apps are still full of components shaped exactly like this. There’s nothing wrong with it!
But notice how much of it is simply fluff. The prop name and the emit name have to match a convention you’re expected to remember.
And a big one, you can’t use v-model on the inner <input> because you’re not allowed to mutate a prop, so you fall back to :value plus @input.
You’ve probably seen the alternative version of this too, the writable computed that people reach for so they can write to the value again:
Clever, but it’s still four extra lines and one more thing to decode every time you read the component.
The Same Component with defineModel
Here’s the whole thing again.
BaseInput.vue
Let’s break it down. defineModel declares the modelValue prop and the update:modelValue emit in one shot. The options object takes the prop options you already know from defineProps. type and required behave exactly the same way, and default has one wrinkle we’ll get to shortly. It also takes two extra options, get and set, which we’ll cover in the modifiers section.
The return value is a writable ref, which means we can hand it straight to v-model on the native input. No :value, no @input, no computed with a setter. When the user types, v-model writes to model, writing to model emits the update, and the parent sees the new value.
A parent that uses this doesn’t change at all; it is unaware of the inner workings of the component:
SignupForm.vue
That’s the part I like most about this macro. It only changes how you write the child. Nothing about the parent changes, so every component that already uses yours keeps working, and you can migrate file by file whenever you happen to be in there.
Required, Default and the Gotcha
You can mark a model as required, which is genuinely useful for components that make no sense without a value:
You can also give it a default:
⚠️ Keep this in mind!
Defaults on a model aren’t the same as defaults on a normal prop. If the parent binds a ref that happens to be undefined, the child will use its default of 'draft', but the parent’s ref stays undefined. The two sides are now showing different values and nothing has told you about it. The child thinks the status is a draft; the parent thinks there’s no status at all.
The official docs flag this too, and my personal rule is simple. If a component is going to declare a default for its model, I initialize the parent ref with a real value instead of leaving it empty. Let the parent own the starting state, and use the child default only as a safety net.
For objects and arrays, wrap the default in a function the same way you would for any other prop:
Multiple v-model Bindings
v-model takes an argument, which means one component can expose more than one two-way binding. Pass the prop name as the first argument to defineModel and you get a separate ref for each one. This has to be a plain string since the macro runs at build time, so a variable would have no value to read yet.
DateRangePicker.vue
And the parent binds each one by name:
ReportFilters.vue
Notice the casing. In the template we write v-model:start-date in kebab-case, and in the child we declare defineModel('startDate') in camelCase. Vue handles the conversion for props the same way it always has, so both spellings refer to the same thing.
Date ranges are the case I hit most often, along with min/max sliders and address blocks. Anything where the component genuinely owns several related values, and where one object prop would be worse. The parent would have to spread the whole object just to change one field.
To be quite honest with you, I try not to go past two or three models on a single component. If you find yourself with more than two, usually it signals that the component is doing too much and wants to be split, or the data really is one object and should travel as one.
Modifiers Without the Pain
Custom v-model modifiers are one of those Vue features almost nobody used before 3.4, and I’m fairly sure the reason was that wiring them up was tedious. You had to declare a second prop with a very specific name, default it to an empty object and check it by hand.
With defineModel, you destructure the return value into a pair. The first element is the ref. The second is the modifiers object.
SlugInput.vue
The parent opts in with a dot:
ArticleForm.vue
Let’s break down what’s happening. The set option is a transformer that runs on the way out, right before the value is emitted to the parent.
Inside it we check modifiers.slug, which is true only when the parent wrote .slug on the binding. If it’s there, we lowercase the text and swap spaces for hyphens. Otherwise, we pass the value through untouched.
Notice the && value guard. The set option runs on every write, including writes you make yourself from inside the component. If a reset somewhere does model.value = null, the transformer would try to call toLowerCase on it and throw. It’s a tiny guard and it stops that crash.
Keep in mind that set never runs when you read the model, so it does nothing on first render. If you want the same protection when reading, that’s what get is for.
There’s a matching get option that transforms on the way in, when you read the model ref inside the child. It’s good for cleaning up a value so your template doesn’t have to keep checking it:
SearchInput.vue
The parent is free to hold null or leave the ref empty, and everything inside the child can treat the value as a string. This is a mild version of the mismatch I warned about earlier, since the child shows an empty string while the parent still holds null. It’s much tamer than inventing a default value, and the first keystroke puts both sides back in step.
One important caveat here.
A get should never change a value the user actually typed. It runs on every read, so anything that reshapes real input will fight the user as they type.
The example above is safe because ?? '' only steps in when there’s nothing there. Any string the user types passes through untouched.
Now, say you write a get that rounds a number to two decimals instead. It looks fine in a demo, but get runs every single time the value is read, so it reformats the number right back into the field. Type 12.567 and the field rewrites itself under you while you’re still typing.
Filling in a missing value is fine. Reformatting a real one is not.
For named models, the modifiers come back from that specific model’s own destructure:
Write v-model:start-date.iso on the parent and only startModifiers sees { iso: true }. The other model’s modifiers object stays empty.
TypeScript
If you’re working in TypeScript, defineModel takes a type parameter.
That gives you Ref<number | undefined>, because nothing guarantees the parent passed anything. Mark it required and the undefined goes away:
Modifier names can be typed too, with a second type parameter as a union of the modifier strings you support:
Now modifiers.rounded type checks and a typo like modifiers.rounde does not. This is a small thing, but it’s the kind of small thing that saves you a confused 10 minutes.
When Not to Reach for a defineModel
defineModel makes two-way binding so cheap to write that it’s tempting to put one on everything.
A model is the right shape when the parent owns a value and the child is a nicer way to edit it. Inputs, selects, toggles, pickers, editors. The child has no opinion about the value beyond how it’s displayed and changed.
It’s the wrong shape when the child is really reporting something rather than editing something. If a component is telling the parent “the user scrolled past this point” or “validation failed,” that’s an event, not a model. Use defineEmits and say what happened. Two-way binding there just gives the parent a piece of state it has no business writing to.
Migrating an Existing Component
If you have a codebase full of the old shape, the swap is mechanical. Delete the modelValue entry from defineProps, delete update:modelValue from defineEmits, delete the handler or the computed, and add one defineModel call. Then replace :value plus @input in the template with a plain v-model.
Two things to watch for while you do it:
- Other props: If your component had props alongside modelValue, keep the defineProps call for those and just remove the model entry. defineModel and defineProps live happily in the same file, they just can’t declare the same prop name twice.
- Other emit sites: If you were emitting update:modelValue from somewhere other than the input handler, say inside a reset method, that becomes a plain assignment like model.value = ''.
One last thing before you start. defineModel is 3.4 and up, and the modifier destructuring shape is part of that same release. If you’re stuck on an older 3.x for whatever reason, the props-and-emit version is still completely valid Vue and there’s no urgency to rewrite anything. This is a quality-of-life feature, not a correctness fix.
Wrapping Up
defineModel takes the most common component pattern in Vue, the two-way bound value, and gives it a single line of syntax instead of three separate pieces. You get a writable ref you can pass straight to v-model, prop options you already know, named models for components with several bindings and modifier support that’s finally worth using.
The defineModel API reference and the Component v-model guide both have more detail, especially around the transformer options.
Happy v-modeling!












