How to programmatically create a component instance in Vue 3?

Viewed 3670

I have a Vue 2 pattern I was using for a common scenario: programmatically creating an instance to open a Modal/Dialog/Lightbox on dynamic content outside of a template.

In Vue 2, I found this pattern:

// DialogService.js

export default {
  alert(text) {
    const DialogClass = Vue.extend(DialogComponentDef);
    let dialog = new DialogClass({ propsData: { text } });

    dialog.$on('close', () => {
      dialog.$destroy();
      dialog.$el.remove();
      dialog = null;
    });

    // mount the dynamic dialog component in the page
    const mountEl = document.createElement('div');
    document.body.appendChild(mountEl);
    dialog.$mount(mountEl);
  },
};

How can I acheive this in Vue 3, knowing Vue.extends, $on & $destroy do not exist anymore? You can see a full example of the DialogService.js by clicking here.

4 Answers

Here's how to do with createApp in Vue 3, but the context (stores, plugins, directives...) will not be kept.

// DialogService.js
import { createApp } from 'vue';

export default {
  alert(text) {
    const mountEl = document.createElement('div');
    document.body.appendChild(mountEl);

    const dialog = createApp({ extends: DialogComponentDef }, {
      // props
      text,
      // events are passed as props here with on[EventName]
      onClose() {
        mountEl.parentNode.remvoeChild(mountEl);
        dialog.unmount();
        dialog = null;
      },
    });

    dialog.mount(mountEl);
  },
};

To keep the context, there's something more complicated that can be seen here with h and render Vue methods : https://github.com/vuejs/vue-next/issues/2097#issuecomment-709860132

Here is the simple way to call and run a component programmatically

/* DialogService.js */
import DialogVue from './Dialog.vue';
import { createApp } from 'vue';

const Dialog = (options = {}) => {
  const onClose = options.onClose;
  const tempDiv = document.createElement('div');
  const instance = createApp(DialogVue).mount(tempDiv);

  instance.title = options.title;
  instance.text = options.text;
  instance.onClose = options.onClose;
  instance.show = true;

  document.body.appendChild(instance.$el);
}

export default Dialog;
<!-- Dialog.vue -->
<template>
  <transition name="fade-bottom">
    <h3 v-if="title">{{ title }}</h3>
    {{ text }}
    <button @click="show = false; onClose()">Cancel</button>
  </transition>
</template>

<script setup>
import { ref, defineExpose } from 'vue'

const show = ref(false)
const title = ref('')
const text = ref('')
const onClose = () => {}

defineExpose({
  title,
  text,
  show,
  onClose
})

</script>

Vue 3 doesn't provide a generic event bus. It can be replaced with lightweight third-party alternatives like mitt or eventemitter3.

A component can be mounted outside application element hierarchy with a teleport. This has been previously available in Vue 2 with third-party portal-vue library. Modals and other screen UI elements are common use cases for it

<teleport to="body">
  <DialogComponent ref="dialog" @close="console.log('just a notification')">
   Some markup that cannot be easily passed as dialog.value.show('text')
  </DialogComponent>
</teleport>

Where DialogComponent controls its own visibility and doesn't need to be explicitly unmounted like in original snippet. A cleanup is performed automatically on parent unmount:

<teleport to="body">
  <div v-if="dialogState">
    <slot>{{dialogText}}</slot>
  </div>
</teleport>

and

let dialogState = ref(false);
let dialogText = ref('');
let show = (text) => {
  dialogText.value = text;
  dialogState.value = true;
} ;
...
return { show };

For more complex scenarios that require to manage multiple instances, or access show outside components in business logic, a teleport needs to be mounted at the top of component hierarchy. In this case an instance of event bus that can be passed through the application can be used for interaction.

I would recommend using the mount-vue-component. It's lightweight and easy to use. Code example:

import MyComponent1 from './MyComponent1.vue'
import MyComponent2 from './MyComponent2.vue'
import { mount } from 'mount-vue-component'

let dynamicComponent = mount(someCondition ? MyComponent1 : MyComponent2, { props: { <someProperties...> }, app: MyVueApp })
Related