10

I would like to dynamically create a component in my Vue 3 app that I have in an SFC, and append it to the DOM. I am using <script setup> style components, which is yet another wrinkle.

This seems unnecessarily hard.

Here's more or less what I want to do:

  1. Fetch some data. Got that.
  2. Make an instance of my Vue Component: Foo.vue.
  3. Hand it that data as a prop.
  4. Append it to the DOM where I want it.

The problem is that I can't do <component :is="Foo:> in the template because I don't know where it will be until long after the template is rendered.

Is there a best practice for this? A simple example some kind soul can provide would be hugely appreciated.

I cannot make heads or tails out of the Vue docs half the time. Sorry, hate to say it, but they are pretty opaque to newbies to Vue, and make me feel dumb.

Here's some pretend code illustrating what I want to do

import Foo from "../components/Foo.vue"

function makeAFoo(p, data){
// instantiate my Foo.vue (not sure how to do this inline), and pass it the data it needs
let foo = new Foo(data); // if only it were this simple, right?
// Append it to p (which is an HTML Element)
p.appendChild(foo)
}
Genericrich
  • 4,611
  • 5
  • 36
  • 55

3 Answers3

23

Option 1: createVNode(component, props) and render(vnode, container)

Creating: Use createVNode() to create a VNode of a component definition (e.g., imported SFC from *.vue) with props, which could be passed to render() to render it on a given container element.

Destroying: Calling render(null, container) destroys the VNode attached to the container. This should be called as cleanup when the parent component unmounts (via unmounted lifecycle hook).

// renderComponent.js
import { createVNode, render } from 'vue'

export default function renderComponent({ el, component, props, appContext }) {
  let vnode = createVNode(component, props)
  vnode.appContext = { ...appContext }
  render(vnode, el)

  return () => {
    // destroy vnode
    render(null, el)
    vnode = undefined
  }
}

Caveat: This approach relies on internal methods (createVNode and render), which could be refactored or removed in a future release.

demo 1

Option 2: createApp(component, props) and app.mount(container)

Creating: Use createApp(), which returns an application instance. The instance has mount(), which can be used to render the component on a given container element.

Destroying: The application instance has unmount() to destroy the app and component instances. This should be called as cleanup when the parent component unmounts (via unmounted lifecycle hook).

// renderComponent.js
import { createApp } from 'vue'

export default function renderComponent({ el, component, props, appContext }) {
  let app = createApp(component, props)
  Object.assign(app._context, appContext) // must use Object.assign on _context
  app.mount(el)

  return () => {
    // destroy app/component
    app?.unmount()
    app = undefined
  }
}

Caveat: This approach creates an application instance for each component, which could be non-trivial overhead if there's a need to instantiate many components simultaneously in the document.

demo 2

Example usage

<script setup>
import { ref, onUnmounted, getCurrentInstance } from 'vue'
import renderComponent from './renderComponent'

const { appContext } = getCurrentInstance()
const container = ref()
let counter = 1
let destroyComp = null

onUnmounted(() => destroyComp?.())

const insert = async () => {
  destroyComp?.()
  destroyComp = renderComponent({
    el: container.value,
    component: (await import('@/components/HelloWorld.vue')).default
    props: {
      key: counter,
      msg: 'Message ' + counter++,
    },
    appContext,
  })
}
</script>

<template>
  <button @click="insert">Insert component</button>
  <div ref="container"></div>
</template>
tony19
  • 125,647
  • 18
  • 229
  • 307
  • The dynamic component created by `render` cannot be inspected by VueDevtools v6.0.0-beta.20 so far. – Alexander Chen Nov 15 '21 at 10:46
  • In case someone is puzzled by destroyComp?.() look up here: https://stackoverflow.com/questions/53860763/what-does-return-local-do-in-this-closure – ShadowGames Oct 07 '22 at 13:33
0

A more simple approach is to use v-if or v-for.

Instead of dealing directly with components you can deal with the state of components and let Vue reactivity do the magic

Here is an example that appends a component dynamically(Toast) while only manipulating the state of the component

Toast.vue file : the v-for is reactive here, whenever a new error is added to the errors's object, it will be rendered

<script setup lang="ts">
import { watch } from 'vue';
import { ref, onUpdated } from 'vue';
import { Toast } from 'bootstrap';

const props = defineProps({
  errors: { type: Array, default: () => [] },
});

onUpdated(() => {
  const hiddenToasts = props.errors.filter((obj) => {
    return obj.show != true;
  });
  hiddenToasts.forEach(function (error) {
    var errorToast = document.getElementById(error.id);
    var toast = new Toast(errorToast);
    toast.show();
    error.show = true;
    errorToast.addEventListener('hidden.bs.toast', function () {
      const indexOfObject = props.errors.findIndex((item) => {
        return item.id === error.id;
      });
      if (indexOfObject !== -1) {
        props.errors.splice(indexOfObject, 1);
      }
    });
  });
});
</script>
<script lang="ts">
const TOASTS_MAX = 5;
export function push(array: Array, data): Array {
  if (array.length == TOASTS_MAX) {
    array.shift();
    array.push(data);
  } else {
    array.push(data);
  }
}
</script>

<template>
  <div
    ref="container"
    class="position-fixed bottom-0 end-0 p-3"
    style="z-index: 11"
  >
    <div
      v-for="item in errors"
      v-bind:id="item.id"
      class="toast fade opacity-75 bg-danger"
      role="alert"
      aria-live="assertive"
      aria-atomic="true"
      data-bs-delay="15000"
    >
      <div class="toast-header bg-danger">
        <strong class="me-auto text-white">Error</strong>
        <button
          type="button"
          class="btn-close"
          data-bs-dismiss="toast"
          aria-label="Close"
        ></button>
      </div>
      <div class="toast-body text-white error-body">{{ item.msg }}</div>
    </div>
  </div>
</template>

ErrorTrigger.vue: here we push an error to the errors's object whenever a click happens

<script setup lang="ts">
import { ref, reactive } from 'vue';
import toast from './Toast.vue';
import { push } from './Toast.vue';

const count = ref(0);
const state = reactive({ errors: [] });

function pushError(id: int) {
  push(state.errors, { id: id, msg: 'Error message ' + id });
}
</script>

<template>
  <toast :errors="state.errors"></toast>

  <button type="button" @click="pushError('toast' + count++)">
    Error trigger: {{ count }}
  </button>
</template>

complete example here : https://stackblitz.com/edit/vitejs-vite-mcjgkl

Anouar BAKRI
  • 314
  • 2
  • 13
0

The mount-vue-component package provides a helper function that uses Option 1 in @tony19's answer.

One caveat: if the component is defined in a .vue file and only created programmatically, you may need to import and register it. Otherwise tree shaking may remove its template, resulting in an empty component being inserted on prod. (This problem seems to principally affect Vue + Vite.)

For example:

import MyComponent from "./MyComponent.vue";
const app = createApp(App).use(router);
app.component("MyComponent", MyComponent);
spacejake
  • 21
  • 1
  • 4