useModal composable
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:
| Property | Type | Default | Description |
|---|---|---|---|
closeOnUnmount | boolean | true | Force-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:
<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
| Scenario | Prefer |
|---|---|
| One-off modal, single call site | openModal |
| Same modal opened multiple times from one place | useModal |
Reactive isOpen to disable a button | useModal |
| Listeners that survive across re-opens | useModal |
| Route guard / utility outside a component | openModal |
