Vue で createApp() を活用してモーダルを動的に管理する方法

# TL;DR

Vue 3 の createApp() を使って 独立した Vue インスタンスごとにモーダルを動的に管理 する方法を紹介。
Pinia を活用してモーダルをスタック構造で管理し、複数のモーダルを順番に開閉できる仕組み を実装。
パフォーマンス面での注意点もあるが、複数のモーダルを同時に扱うシナリオには有効なアプローチ。

こんなかんじに動作します
https://vue-playground-flame.vercel.app/

# はじめに

Vue でモーダルを実装する際、通常は v-if や v-show を使って表示・非表示を切り替えるのが一般的かと思われます。あるいはModalを出し分けるProviderとしてのVue Elementを使ったりすることもあると思います。しかし、複数のモーダルを重ねて表示する場合、管理が複雑になりがちです。

そこで、Vue の createApp() を利用し、新しい Vue インスタンスを作成してモーダルをスタックしていく方法を紹介します。この方法なら、各モーダルが独立した Vue インスタンスとして動作し、管理がシンプルになります。

今回記載する例は下記を満たすものとします

- モーダルの上に別のモーダルを重ねて表示できる
- モーダルは一つずつ消すことができる
- モーダルはまとめて消すこともできる
- モーダルがいくつ重なってもBackdropは1つしか表示しない

## 環境

本記事では、以下の環境を使用しています。

- Vue 3 (createApp() を使用) script setup syntaxを利用
- TypeScript
- Pinia（状態管理）
- Vite（ビルドツール）

Pinia は、モーダルのスタック管理や状態共有をシンプルに行うために導入しています。

## 作成するファイル

- App.vue
- BaseModal.vue
- AlertModal.vue
- ConfirmModal.vue
- modalManager.ts

### App.vue

一旦空のモーダルを表示するためのボタンを置いたものを用意します。

```vue:App.vue
<script setup lang="ts">
import { useModalStore } from '@/stores/modalManager'
defineProps<{
  msg: string
}>()
const modalStore = useModalStore()
const newModal = () => {
  modalStore.openModal('confirm')
}
</script>
<template>
  <div class="wrapper">
    <button @click="newModal">Open Modal</button>
  </div>
</template>
```

### modalManager.ts

```typescript:modalManager.ts
import { createApp, ref } from 'vue'
import type { App } from 'vue'
import { defineStore } from 'pinia'
import AlertModal from '@/components/AlertModal.vue'
import ConfirmModal from '@/components/ConfirmModal.vue'

interface AppListItem {
  app: App<Element>
  modalContainer: HTMLElement
}

type ModalTypes = 'alert' | 'confirm'

function getModalComponent(type: ModalTypes) {
  switch (type) {
    case 'alert':
      return AlertModal
    case 'confirm':
      return ConfirmModal
    default:
      return AlertModal
  }
}

export const useModalStore = defineStore('modal', () => {
  const modalStack = ref<AppListItem[]>([])
  function openModal(type: ModalTypes) {
    const modalContainer = document.createElement('div')
    document.body.appendChild(modalContainer)
    const ModalComponent = getModalComponent(type)
    const app = createApp(ModalComponent, {
      modalNumber: modalStack.value.length,
    })
    app.config.globalProperties.$rootProps = app._component.props
    app.mount(modalContainer)

    modalStack.value.push({
      app,
      modalContainer,
    })
  }
  function closeModal() {
    const appListItem = modalStack.value.pop()
    if (appListItem?.app) {
      appListItem.app.unmount()
      document.body.removeChild(appListItem.modalContainer)
    }
  }
  function closeAll() {
    while (modalStack.value.length) {
      closeModal()
    }
  }
  return { openModal, closeModal, closeAll }
})

```

モーダルの種類に応じて適切なコンポーネントを取得し、動的に createApp() を使ってマウント・アンマウントする仕組みを作ります。


### BaseModal.vue

```vue:BaseModal.vue
<script setup lang="ts">
import { computed, getCurrentInstance, onMounted } from 'vue'
const instance = getCurrentInstance()
// Vue インスタンスのrootPropsとして定義されたmodalの数を取得
const propNumber = instance?.appContext?.app?._props?.modalNumber ?? 0
const dialogId = computed(() => `modal-${propNumber}`)
onMounted(() => {
  const dialog = document.getElementById(dialogId.value) as HTMLDialogElement
  dialog?.showModal()
})
</script>
<template>
  <dialog :id="dialogId" class="modal" :class="{ isBase: propNumber === 0 }">
    <slot></slot>
  </dialog>
</template>
<style scoped>
.modal {
  min-width: 300px;
  max-width: 600px;
  padding: 16px;
  background-color: white;
  border-radius: 8px;
  box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
  color: black;
}
.modal-backdrop {
  position: fixed;
  top: 0;
  left: 0;
  width: 100vw;
  height: 100vh;
  display: flex;
  justify-content: center;
  align-items: center;
  justify-items: center;
}
.modal-backdrop.isBase {
  background-color: rgba(0, 0, 0, 0.5);
}

dialog {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  border: none;
  background-color: white;
}
dialog::backdrop {
  background-color: rgba(0, 0, 0, 0.5);
  display: flex;
  justify-content: center;
  align-items: center;
  justify-items: center;
}
dialog:not(.isBase)::backdrop {
  background-color: transparent;
}
</style>

```

BaseModalはモーダルのバックドロップや左右中央寄せを行うコンポーネントです。
modalのvueインスタンスに付与されたモーダルがいくつ目なのかの数を取得して最初のインスタンスである場合のみバックドロップを半透明の黒にかけています。
modal-backdropにただ半透明の黒をかけてしまうとバックドロップが重なるごとに背景が黒く黒くなっていきます。

### AlertModal.vue

```vue:AlertModal.vue
<script setup lang="ts">
import ModalBase from './ModalBase.vue'
import { useModalStore } from '@/stores/modalManager'
const modalStore = useModalStore()
function closeModal() {
  modalStore.closeAll()
}
</script>
<template>
  <ModalBase>
    <div class="modal">
      <div>
        <h2>Alert</h2>
      </div>
      <div>
        <p>Are you sure you want to close all modal?</p>
      </div>
      <div class="modal-footer">
        <input class="alert-button" type="button" value="Close" @click="closeModal" autofocus />
      </div>
    </div>
  </ModalBase>
</template>
<style scoped>
.modal {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 8px;
}

.alert-button {
  padding: 8px 16px;
  background-color: #f00;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

.alert-button:focus {
  outline: blue auto 5px;
}
```

AlertModalはコンファームの後に表示され、ボタンの押下時にすべてのモーダルを閉じるpinia actionを呼んでいます。

### ConfirmModal.vue

```vue:ConfirmModal.vue
<script setup lang="ts">
import ModalBase from './ModalBase.vue'
import { useModalStore } from '@/stores/modalManager'
const modalStore = useModalStore()
function closeModal() {
  modalStore.closeModal()
}
function openModal() {
  modalStore.openModal('alert')
}
</script>
<template>
  <ModalBase>
    <form class="modal" @submit.prevent="openModal">
      <div>
        <h2>Confirm</h2>
      </div>
      <div>
        <p>Are you sure you want to see new modal?</p>
      </div>
      <div class="modal-footer">
        <input class="cancel button" @click="closeModal" type="button" value="Cancel" />
        <input
          id="confirmButton"
          class="confirm button"
          @click="openModal"
          type="submit"
          autofocus
          value="OK"
        />
      </div>
    </form>
  </ModalBase>
</template>
<style scoped>
.modal {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 8px;
}

.modal-footer {
  display: flex;
  gap: 8px;
}

.button {
  padding: 8px 16px;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-weight: bold;
}
.button:focus {
  outline: blue auto 5px;
}

.cancel {
  background-color: #888;
  color: white;
}

.confirm {
  background-color: rgb(0, 201, 0);
  color: white;
}
```

ConfirmModalはキャンセルした場合にはモーダルをひとつ、OKだった場合にはAlertModalを上から開くようになっています。

### まとめ

この方法を使うと、Vue の createApp() を利用して新しいインスタンスを作成し、独立したモーダルを動的に管理できます。
また、Pinia を活用することで、開かれているモーダルの数やスタックの状態を簡単に管理できます。

メリット
✅ モーダルごとに独立した Vue インスタンスで動作
✅ 動的に追加・削除が可能
✅ Pinia で状態管理をシンプルに実装可能

デメリット
❌ パフォーマンスに影響する可能性（大量のモーダルを開くと、新しい Vue インスタンスが増えてメモリを消費する）
❌ グローバルな状態共有が難しい（Pinia などの状態管理なしでは、モーダル間のデータ共有が難しくなる）
❌ イベントリスナー管理が必要（モーダルを開いたままvue-routerによるページ遷移すると、不要なインスタンスが残る可能性がある）

この手法は、一時的に開くモーダルが少数の場合に適しており、頻繁に開閉する UI には適さない場合もある ため、使用シーンを考慮して実装することをおすすめします。

こんなかんじに動作します
https://vue-playground-flame.vercel.app/


## 追記

`<div>`を用いたデモになっていたので、よりモダンな`<dialog>`タグを用いたものに変更しました