Rendering modals with functions in Vue

Recently, I ran into an interesting task: we need to create modal windows that would be rendered using function calls. After researching various libraries and articles, I collected all the methods I knew in one article. More details under the cut.

We will not specifically consider the standard placement of popups using teleport And v-show with reactive state inside the parent component. This article deals with cases where popups should not pollute other components with data for their props. Also, we will not consider the pattern UIStatewhere for each popup in the store (Vuex/Pinia) the state is written whether the popup is open.

Event emitting

This method is based on the pub/sub pattern. By the way, this is the most primitive way and ugly the way you can think of.

We can implement the pub/sub pattern ourselves, it’s not that difficult to do, a primitive pub/sub implementation is provided below, which will only call events and subscribe to them.

// Список заранее прописанных ивентов
export enum EmitterEvents {
  SHOW_POPUP
}

// Интерфейс для эмиттера (объекта, который будет содержать все функции
// которые нужно вызвать при тригере)
type Emitter = Record<EmitterEvents, Array<() => void>>;

const emitter: Emitter = {
  [EmitterEvents.SHOW_POPUP]: []
};

// Используем composable для того чтобы вернуть нужные нам функции
export const useEmitter = () => {
  const trigger = (event: EmitterEvents) => emitter[event].forEach(cb => cb());
  
  const bind = (event: EmitterEvents, callback: () => void) => {
    if (emitter[event].includes(callback)) {
      console.warn('This callback is already in emitter! 👾');
      return;
    }

    emitter[event].push(callback);
  }

  const unbind = (event: EmitterEvents, callback: () => void) => {
    const callbackIndex = emitter[event].findIndex(item => item === callback);

    if (callbackIndex === -1) {

      // Создаем ошибку для отслеживания стека вызовов
      console.error(new Error('There\'s no event to delete! 🤖'));
      return;
    }

    emitter[event].splice(callbackIndex, 1);
  }

  return {trigger, bind, unbind};
}

Naturally, you can rewrite this emitter a little and add the ability to pass arguments to callbacks, but for now we don’t need it, we just need to draw the modal window.

Now all we need to do is place our popup somewhere (for example in App.vue) and trigger it with useEmitter.

The implementation of the popup will be something like this:

<template>
  <div class="popup">
  <!-- ... -->
  </div>
</template>

<script setup lang="ts">
  import { useEmitter, EmitterEvents } from 'composables/useEmitter';
  const isVisible = ref(false);

  const { bind } = useEmitter();
  bind(EmitterEvents.SHOW_POPUP, () => {
    isVisible.value = true;
  })
</script>

Now, inside the component with which we need to call this popup, we need to register a trigger for the event:

<template>
  <!-- ... -->
  <button @click="showPopup">Показать попап</button>
  <!-- ... -->
</template>

<script setup lang="ts">
  import { useEmitter, EmitterEvents } from 'composables/useEmitter';
  const { trigger } = useEmitter();

  const showPopup = () => {
    trigger(EmitterEvents.SHOW_POPUP);
  };
</script>

Now consider the pros and cons of this approach:

pros:

Minuses:

  • Difficult to debug in case of a bug;

  • A large number of events will be created if there are a lot of pop-ups;

  • Easy to create callbackhall;

  • If the pub/sub pattern is going to be used for more than just displaying popups, then it’s easy to write paste code;

Using provide/inject

In this case, provide/inject will be very similar to the pub/sub pattern. The point is that we will create a reactive state (whether the popup is open) and we will throw it where we need it.

This method is not much different from the UISTate pattern, where we indicate in the store boolean values ​​which parts of the graphical interface are currently active. The difference is that we use the built-in features of Vue without the use of third-party libraries.

In order to use this method, we need to create a dictionary of symbols and reactive states in a separate file:

import { reactive } from 'vue';
import type { InjectionKey } from 'vue';

export {
  ourPopup: {
    key: Symbol() as InjectionKey<boolean>,
    state: reactive({
      isVisible: false,
      options: {}, // Здесь можем добавить и типизировать пропсы
    }),
  }
}

Now we need to create a reactive variable at the root of the application (App.vue) that we can change when we need it:

// App.vue
<script setup lang="ts">
  import { ourPopup } from './popupState';
  provide(ourPopup.key, ourPopup.state);
</script>

Inside the popup, we will need to attach to the value:

// Popup.vue
<script setup lang="ts">
  import { ourPopup } from './popupState';
  const ourPopupState = inject(ourPopup.key);
</script>

We can change this value inside another component using a reactive value:

// TriggerParent.vue
<script setup lang="ts">
  import {ourPopup} from './popupState';
  const {isVisible: isOurPopupVisible = inject(POPUP_KEY);

  const togglePopup(state: boolean) {
    isOutPopupVisible.value = state;
  }
</script>

Above are examples to understand how this principle works. The togglePopup methods can be transferred separately so that they do not lie inside the component, I think everyone understands this, but just in case, I decided to clarify 👀

pros:

Minuses:

  • The bug will still be difficult to catch, as any component can use inject;

  • We leave a lot of data in the global scope;

  • If there are other provide/inject, then working with popups and their states will become more complicated;

standalone application

It is important to clarify that it seems redundant to create a separate application for rendering the modal window, if only because the memory consumption will grow a lot, but this option also needs to be shown.

The method is quite simple: we will create a composable, into which we can pass the application context and props that need to be rendered, and then we will mount it.

// composable/useOurPopup.ts
import { AppContext, createApp } from 'vue'
import OurPopupComponent, {ComponentProps} from './Component';

interface UseOurPopupArgs {
  mountNode?: Element,
  props: ComponentProps,
  appContext?: AppContext,
}

export default function renderComponent({ mountNode, props, appContext }: UseOurPopupArgs) {
  let app = createApp(OurPopupComponent, props)

  if (appContext) {
    Object.assign(app._context, appContext) // Дополняем исходный контекст приложения
  }

  const show = () => {
    app
      .mount(mountNode ?? document.getElementById('#popup') as Element); // Маунтим к специфической ноде или ноде по дефолту (#popup)
  };

  const hide = () => {
    app.unmount()
  }

  return {
    show,
    hide,
  }
}

Now all that needs to be done in the component from which we will call our popup is to use the composable we created:

// ParentComponent.vue
<script setup lang="ts">
  import { useOurPopup } from 'composables/useOurPopup';

  const popup = useOurPopup();
  popup.show();
</script>

pros:

  • Creating popups is very easy to scale. We only need to modify one file;

  • Component states are inside composable, which means states are not in the global scope;

  • The code is easy to debug;

Minuses:

  • For each new popup, a separate application will be created, which is why there is a high probability of a memory leak;

External rendering with createVNode

I think this is the best way to render modal windows using functions. The bottom line is that we will turn the component into a virtual node (VNode), and then render it directly into the DOM tree, without creating a new application.

First we need to create a composable in which we will create our virtual node and render it:

// composables/useOurPopup.ts
import { AppContext, createApp } from 'vue'
import OurPopupComponent, {ComponentProps} from './Component';

interface UseOurPopupArgs {
  mountNode?: Element,
  props: ComponentProps,
  appContext?: AppContext,
}

import { createVNode, render } from 'vue'

export default function useOurPopup({mountNode, props, appContext}: UseOurPopupArgs) {
  let vnode = createVNode(OurPopupComponent, props)
  vnode.appContext = appContext ?? null;

  const show = () => {
    const defaultNode = document.getElementById('#popup') as Element;
    render(vnode, mountNode ?? defaultNode);
  };

  const hide = () => {
    const defaultNode = document.getElementById('#popup') as Element;
    render(null, mountNode ?? defaultNode);
  };

  return {
    show,
    hide
  };
}

It may seem that almost nothing has changed, but in this code snippet, instead of creating a new application, we directly render our component into the DOM tree. Memory consumption will be much lower.

Using such a composable will not differ from what we saw when creating the application:

// ParentComponent.vue
<script setup lang="ts">
  import { useOurPopup } from 'composables/useOurPopup';

  const popup = useOurPopup();
  popup.show();
</script>

pros:

  • We can easily expand our set of popups;

  • Memory consumption is much lower compared to the previous option;

  • Component states are in scope composable/useOurPopup.ts;

  • Bugs are easy to trace (debug);

Instead of a conclusion

If you liked this article, then you can always go to my blogthere is more related information about web development.

If you have any questions – feel free to ask them in the comments. Have a good time! 💁🏻‍♂

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *