Guide

useModal composable

Bind a modal component to a reactive controller — persistent listeners, reactive isOpen, auto-cleanup.

useModal composable

useModal wraps openModal with a persistent controller. Use it when you open the same component multiple times from one place, want a reactive isOpen, or need event listeners that survive across re-opens.

Signature

function useModal<T = unknown, C extends Component = Component>(
  component: C,
  defaults?: UseModalDefaults<C>
): {
  open(options?: OpenModalOptions<C>): Promise<T>
  close(opts?: { ignoreGuard?: boolean; instantExit?: boolean }): void
  on(event: string, handler: (...args: any[]) => void): void
  off(event: string, handler: (...args: any[]) => void): void
  isOpen: ComputedRef<boolean>
  isTop: ComputedRef<boolean>
  instanceId: Ref<number | null>
}

UseModalDefaults<C> extends OpenModalOptions<C> with one extra flag:

PropertyTypeDefaultDescription
closeOnUnmountbooleantrueForce-close the modal when the owning scope unmounts.

props and on from defaults merge into every .open() call.

Persistent listeners

The main reason to reach for useModal. Listeners registered via defaults.on or controller.on() fire on every open — register once, react every time:

Dashboard.vue
<script setup lang="ts">
import { useModal } from '@kolirt/vue-modal'
import EditDialog from './EditDialog.vue'

const editor = useModal<{ name: string }>(EditDialog, {
  on: {
    progress: (pct: number) => updateProgressBar(pct)
  }
})

editor.on('save', (data) => store.update(data))

async function edit(id: number) {
  await editor.open({ props: { id } }).catch(() => null)
}
</script>

<template>
  <button :disabled="editor.isOpen.value" @click="edit(42)">Edit</button>
  <button :disabled="editor.isOpen.value" @click="edit(99)">Edit other</button>
</template>

Both progress and save fire for every open, no re-registration. Remove with editor.off(event, handler).

Default props merging

props from defaults and props from each .open() call merge shallowly:

const modal = useModal(EditDialog, {
  props: { mode: 'edit', readOnly: false }
})

modal.open({ props: { id: 42 } })
// effective props: { mode: 'edit', readOnly: false, id: 42 }

modal.open({ props: { id: 99, readOnly: true } })
// effective props: { mode: 'edit', readOnly: true, id: 99 }

Reactive open state

isOpen is a computed ref — true while this instance is mounted in the stack, regardless of whether another modal is on top. Bind it to UI:

<button :disabled="editor.isOpen.value">Edit</button>

isTop is true only when this instance is the topmost modal in the global stack. Use it to gate behavior that should only apply to the active modal (e.g. global key handlers):

watch(editor.isTop, (top) => {
  if (top) attachShortcuts()
  else detachShortcuts()
})

instanceId exposes the live modal's id, or null when closed:

if (editor.instanceId.value !== null) {
  closeModalById(editor.instanceId.value, { ignoreGuard: true })
}

Auto-close on unmount

By default, when the component that called useModal unmounts, the modal is force-closed (guards skipped, animation skipped). Disable this if you want the modal to outlive its parent:

const modal = useModal(HeavyDialog, { closeOnUnmount: false })

Call at setup top-level

useModal registers a scope dispose hook. Call it once at setup time, not inside event handlers:

// ✓ good
const modal = useModal(MyDialog)
function onClick() { modal.open() }

// ✗ bad — new instance and dispose hook on every click
function onClick() {
  const modal = useModal(MyDialog)
  modal.open()
}

useModal vs openModal

ScenarioPrefer
One-off modal, single call siteopenModal
Same modal opened multiple times from one placeuseModal
Reactive isOpen to disable a buttonuseModal
Listeners that survive across re-opensuseModal
Route guard / utility outside a componentopenModal
Copyright © 2026