How defineModel simplifies v-model in custom Vue components

olehhladkov
2026-02-25T22:35:33Z
In this article, I show a practical use case: a custom modal component where defineModel controls visibility — no props, no emit, no boilerplate.
TL;DR:
defineModel replaces the traditional
modelValue+update:modelValuepattern with a single reactive binding.
When Vue introduced defineModel, it looked nice in theory, but I wanted to see how it feels in a real component.
So instead of a contrived counter example, I tried it with something we all build at some point: a modal.
The modal itself uses the native <dialog> element, but that’s not the main point here.
The interesting part is how clean the component API becomes with defineModel.
The Goal
I want to be able to write this:
<MyModal v-model="isModalOpen" />
…without writing:
-
modelValueprop -
update:modelValueemit - extra sync logic
Just v-model and done.
Demo
App.vue
<script setup>
import { ref } from 'vue';
import MyModal from './components/MyModal.vue';
const isModalOpen = ref(false);
</script>
<template>
<main>
<button @click="isModalOpen = true">Open Modern Modal</button>
<MyModal v-model="isModalOpen">
<h1>Custom Content</h1>
<p>This replaces the default slot content!</p>
</MyModal>
</main>
</template>
Nothing special here:
-
isModalOpenis just aref -
v-modelcontrols the modal - slot lets us pass custom content
The parent component doesn’t care how the modal works internally.
MyModal.vue
<script setup>
import { useTemplateRef, watchEffect } from 'vue';
// The magic: Two-way binding in one line
const isVisible = defineModel({ default: false });
const modal = useTemplateRef('modal');
watchEffect(() => {
if (!modal.value) return;
isVisible.value ? modal.value.showModal() : modal.value.close();
});
</script>
<template>
<dialog ref="modal" @close="isVisible = false">
<slot>
<h1>Default Modal Title</h1>
<p>This is the default content of your slot.</p>
</slot>
<button @click="isVisible = false">Close</button>
</dialog>
</template>
The Important Part: defineModel
This line is doing all the work:
const isVisible = defineModel({ default: false });
That gives us:
- a reactive value
- automatic
v-modelsupport - two-way binding
- zero emit code
Before, we had to write:
defineProps({ modelValue: Boolean })
defineEmits(['update:modelValue'])
Plus sync logic between them.
Now it’s literally one line.
Why This Is a Good defineModel Use Case
A modal is perfect for this because:
- it has a single source of truth (
open/closed) - parent wants control
- component wants internal control (close button, ESC key, etc.)
With defineModel, both sides can update the same value:
- Parent opens it
- Modal closes itself
- State stays in sync automatically
No custom events. No glue code.
What This Shows About defineModel
Using defineModel makes custom components feel closer to native inputs:
- clean API
- predictable behavior
- minimal code
- less mental overhead
Instead of thinking in terms of:
“prop + emit + sync”
You just think:
“this component has a value”
When This Pattern Makes Sense
Good fit when:
- component has a single main state
- parent and child both need to update it
- you want a clean
v-modelAPI
Examples:
- modal
- toggle
- tabs
- drawer
- select
- stepper
Final Thoughts
defineModel is small, but it changes how custom components feel to write.
Instead of ceremony, you get:
- one line
- one state
- one API
This modal is just a convenient example of that.
If you haven’t tried defineModel yet, I highly recommend giving it a shot in a real component.
It removes a lot of the friction around v-model and makes custom components easier to reason about.
If you have other use cases for defineModel or ideas to improve this example, I’d love to read them in the comments.