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 (1)

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.

16 Libraries You Should Know as a React Developer

>> Check out this classic DEV post <<