Making v-model Model Value Optional in Vue.js

While writing my Vue.js UI Library, Inkline, I had to find a way to make some components work both with and without providing a model value (v-model). While it's not a common scenario, it's something that you'll definitely come across if you're writing a library and you're serious about Developer Experience (DX).

I call them Optionally Controlled Components, because they're supposed to work out of the box without providing a v-model, but will give you complete control over their state if you do provide a v-model.

The Menu Example

One prime example of an Optionally Controlled Component would be a menu that can be opened (expanded) or closed (collapsed). Let's call the component simply MyMenu.

From a Developer Experience perspective, you'll probably want your library user to be able to drop a <my-menu> into their code and start adding collapsible content right away, without having to worry about handling its open or closed state.

Here's what the component would look like without v-model support:

<template>
    <div class="my-menu">
        <button @click="toggleMenu">
            Menu
        </button>
        <menu v-show="open">
            <slot />
        </menu>
    </div>
</template>

<script>
export default {
    name: 'MyMenu',
    data() {
        return {
            open: false
        };
    },
    methods: {
        toggleMenu() {
            this.open = !this.open;
        }
    }
}
</script>

The Optional Model Value

So far so good. Let's consider the following scenario: your user wants to be able to open or close the menu from somewhere else. We know we can open and close the menu internally at this point, but how do we allow the library user to optionally control the state?

There's a future-proof solution I found, that will save you a lot of trouble. Here's what it looks like:

<template>
    <div class="my-menu">
        <button @click="toggleMenu">
            Menu
        </button>
        <menu v-show="open">
            <slot />
        </menu>
    </div>
</template>

<script>
export default {
    name: 'MyMenu',
    emits: [
        'update:modelValue'
    ],
    props: {
        modelValue: {
            type: Boolean,
            default: false
        }
    },
    data() {
        return {
            open: this.modelValue
        };
    },
    methods: {
        toggleMenu() {
            this.open = !this.open;
            this.$emit('update:modelValue', this.open);
        }
    },
    watch: {
        modelValue(value) {
            this.open = value;
        }
    }
}
</script>

Try a basic example out live on CodeSandbox.

You can see above that I've added the usual modelValue prop to provide v-model support in Vue 3, but mainly I've done three things:

  • I'm setting the initial value of our internal open state property to be equal to the one provided via v-model. This works wonders, because when there's no v-model it would be equal to the specified default, false in our case.
  • I'm emitting an update:modelValue event every time I change the value of this.open internally
  • I've added a watcher that ensures I'm always keeping the internal open value in sync with the incoming external modelValue prop.

Conclusion

Awesome, isn't it? It's important to never forget about Developer Experience. Something as small as this can add up to precious hours of saved development time if done correctly and consistently.

I hope you learned something interesting today. I'd love to hear how the Optionally Controlled Components pattern helped you out, so feel free to reach out to me. Happy coding!

P.S. Have you heard that Inkline 3 is coming with Vue 3 support? Read more about it on GitHub.