DEV Community

Cover image for How defineModel simplifies v-model in custom Vue components
olehhladkov
olehhladkov

Posted on

How defineModel simplifies v-model in custom Vue components

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:modelValue pattern 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" />
Enter fullscreen mode Exit fullscreen mode

…without writing:

  • modelValue prop
  • update:modelValue emit
  • extra sync logic

Just v-model and done.


Demo

👉 Live demo (StackBlitz)


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>
Enter fullscreen mode Exit fullscreen mode

Nothing special here:

  • isModalOpen is just a ref
  • v-model controls 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>
Enter fullscreen mode Exit fullscreen mode

The Important Part: defineModel

This line is doing all the work:

const isVisible = defineModel({ default: false });
Enter fullscreen mode Exit fullscreen mode

That gives us:

  • a reactive value
  • automatic v-model support
  • two-way binding
  • zero emit code

Before, we had to write:

defineProps({ modelValue: Boolean })
defineEmits(['update:modelValue'])
Enter fullscreen mode Exit fullscreen mode

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-model API

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.

Top comments (0)