DEV Community

Cover image for Reusable dynamic modal on Vue 3
Frangeris Peguero for Cloud(x);

Posted on

Reusable dynamic modal on Vue 3

Reusable dynamic modal on Vue 3

Most of the time on frontend development the best way to keep a consistent way of building components is trying to make them reusable every time we can, but sometimes the framework itself can make it a bit hard if we don’t have deep knowledge of its internal API, specifically the way it handles view instance and state component data.

What makes a good reusable modal?

  • ✅ Dynamic views
  • ✅ Customizable actions with callbacks (buttons)
  • ✅ easy-peasy instantiation

For simplicity we will be using:

Let’s start by defining a very basic modal template using daisy classes:

<!-- @/components/x-modal.vue -->

<template>
  <div>
    <div class="modal modal-open">
      <div class="modal-box relative">
        <label class="btn btn-sm btn-circle absolute right-2 top-2"></label>
        <h1>Hey, it works 👏🏽</h1>
      </div>
    </div>
  </div>
</template>
Enter fullscreen mode Exit fullscreen mode

To be able to access it from anywhere and render any type of view, the modal needs to be accessible from any component that wants to use it, for this reason the modal component needs to be placed on the root parent view where all the views will be rendered, assuming Vue router is being used:

<template>
  <x-modal />
  <router-view />
</template>

<script lang="ts" setup>
  import XModal from "@/components/x-modal.vue";
</script>
Enter fullscreen mode Exit fullscreen mode

👏🏽 You should be able to see our beautiful modal rendered!

Setup the store (state management)

As it's a reusable global modal, we need to keep global track if this modal is opened or closed and also have the ability to control it from any other component, maintaining a unique instance. This is where Pinia comes in.

Lets define a new store specifically to interact with the modal:

// @/store/modal.ts

import { markRaw } from "vue";
import { defineStore } from "pinia";

export type Modal = {
  isOpen: boolean,
  view: object,
  actions?: ModalAction[],
};

export type ModalAction = {
  label: string,
  callback: (props?: any) => void,
};

export const useModal = defineStore("modal", {
  state: (): Modal => ({
    isOpen: false,
    view: {},
    actions: [],
  }),
  actions: {
    open(view: object, actions?: ModalAction[]) {
      this.isOpen = true;
      this.actions = actions;
      // using markRaw to avoid over performance as reactive is not required
      this.view = markRaw(view);
    },
    close() {
      this.isOpen = false;
      this.view = {};
      this.actions = [];
    },
  },
});

export default useModal;
Enter fullscreen mode Exit fullscreen mode

Now we have the store, lets connect it to our modal template so we can use its reactive references:

<template>
  <div>
    <!-- isOpen is reactive and taken from the store, define if it is rendered or not -->
    <div v-if="isOpen" class="modal modal-open">
      <div class="modal-box relative">
        <!-- @click handles the event to close the modal calling the action directly in store -->
        <label
          class="btn btn-sm btn-circle absolute right-2 top-2"
          @click="modal.close()"
          ></label
        >

        <!-- dynamic components, using model to share values payload -->
        <component :is="view" v-model="model"></component>

        <div class="modal-action">
          <!-- render all actions and pass the model payload as parameter -->
          <button
            v-for="action in actions"
            class="btn"
            @click="action.callback(model)"
          >
            {{ action.label }}
          </button>
        </div>
      </div>
    </div>
  </div>
</template>

<script lang="ts" setup>
  import { reactive } from "vue";
  import { storeToRefs } from "pinia";
  import { useModal } from "@/stores";

  const modal = useModal();

  // reactive container to save the payload returned by the mounted view
  const model = reactive({});

  // convert all state properties to reactive references to be used on view
  const { isOpen, view, actions } = storeToRefs(modal);
</script>
Enter fullscreen mode Exit fullscreen mode

<component> is the way that vue handle dynamic components where :is receive the view that needs to be rendered, more info here.

It also support using the v-model directive to be able to share values dynamically, but how does this works?

Once we have the view mounted inside <component> we can make use of v-model to send and update reactive references emitting events from the view we are mounting. Internally from the view we want to render: emit a "update:modelValue" event that will update the reference passed via v-model back, eg:

<!-- MyViewToRender.vue -->
<script lang="ts" setup>
  import { watch } from "vue";

  // no need to import defineEmits
  const emit = defineEmits(["update:modelValue"]);

  // when someVar changes, it will update the reference passed via v-model
  watch(someVar, (value) => {
    emit("update:modelValue", value);
  });
</script>
Enter fullscreen mode Exit fullscreen mode

Make use of the modal

To be able to control and populate data into the modal, we'll use the store defined as follow:

<template>
  <div>
    <button class="btn" @click="handleOnClickOpenModal">Open</button>
  </div>
</template>

<script lang="ts" setup>
  import useModal from "@/stores/modal";
  import MyViewToRender from "@/views/MyViewToRender.vue";

  const modal = useModal();
  function handleOnClickOpenModal() {
    modal.open(MyViewToRender, [
      {
        label: "Save",
        callback: (dataFromView) => {
          console.log(dataFromView);
          modal.close();
        },
      },
    ]);
  }
</script>
Enter fullscreen mode Exit fullscreen mode

When a click event on the button occurs, it will call handleOnClickOpenModal which at the same time will call the open action from the store, which changes the isOpen variable from the state, and as the modal is observing this value it will render or not depending of the value as follow:

🎉 Hurra!!! you have made a reusable modal on top of composition API and pinia.

Dynamic modal on vue 3

What if we want to pass a payload to our view?

Dependency injection is a programming technique / design pattern in which an object or function receives other objects or functions that it depends on.

Vue natively implements a mechanism to handle this pattern, this implementation is known as Provide / Inject, more info here. A parent component can serve as a dependency provider for all its descendants. Any component in the descendant tree, regardless of how deep it is, can inject dependencies provided by components up in its parent chain. The issue with this approach is that it only applies to direct descendants from parent (not parallel components) and as the modal view is not strictly a child of the component invoking it, it won't work as intended.

Another way is to add a field property to the state of the modal on pinia (payload) and pass any reactive references from there, and then receive that object from the view we render.

Hope it helps, cheers 🍻

Top comments (10)

Collapse
 
Sloan, the sloth mascot
Comment deleted
Collapse
 
frangeris profile image
Frangeris Peguero

Hi there,

MyViewToRender.vue is just an example file of the view I want to render, it can be anything inside, is an empty file without template just to represent in typescript how share values between the view that render the modal and the modal via v-modal in case you want to pass references or update values on parent.

Regarding someVar, is just an example of observing a variable and reacting to the change calling the emit and passing the value, this is the way to "let the parent knows" that something inside my modal changed, is not required if you don't need to share values

Collapse
 
robinesavert profile image
Robine Savert

Great article! Now how do I go about closing the modal after a Pinia store succesfull call?

Collapse
 
frangeris profile image
Frangeris Peguero

What do you mean with "after a Pinia store succesfull call"?

Collapse
 
robinesavert profile image
Robine Savert

Never mind, figured it out already with a modal instance!

Collapse
 
thekinng96 profile image
Gen Yap Feng Yuan

Hi, could you share your source code for the example project? I followed your description but it doesnt work as expected, wonder which part I might do it wrong.

Collapse
 
frangeris profile image
Frangeris Peguero

Sorry for delay response, did you end up making it works? what error are you facing?

Collapse
 
alfredogael_01 profile image
Alfredo Gael

I'm getting some errors trying to get it to work on my homepage, is it possible that you can assist me? I keep getting this error when I click on my test modal button:

[Vue warn]: Unhandled error during execution of scheduler flush. This is likely a Vue internals bug. Please open an issue at new-issue.vuejs.org/?repo=vuejs/core
at
at ref=Ref< Proxy(Object) {__v_skip: true} > >
at
at

I can provide screenshots if neccesary.

Collapse
 
alfredogael_01 profile image
Alfredo Gael

It won't open when clicking the button. I don't really understand what the function of "someVar" is tho, and it marks it as an error, am I doing something wrong? What should I put in its place? Or what can I do to fix the error? In need of help

Collapse
 
maximstone profile image
Maxim Kosterin

Please use this instead github.com/harmyderoman/vuejs-conf...