A Vue 3 `<DeleteButton>` awaits a confirm dialog, then emits delete, but sometimes nothing happens and it cannot show a spinner; when should it take an onDelete callback prop instead?
answer
- emit returns nothing useful
- an unmounted instance stays silent
- a prop can be awaited
- a prop can be checked for presence
basics
~20 semit() returns nothing the child can await and is silently ignored once the component has unmounted. When the child needs the parent's result, such as a promise for a pending state, a function prop like onDelete fits better.
solid answer
~50 sTwo limits of `emit` show up in this scenario. First, `emit` discards the handler's return value, so `<DeleteButton>` cannot `await` the parent's deletion to show a spinner or an error. Second, if the button is **unmounted** while the dialog is open, for example because the menu that rendered it closed, a later `emit` returns early and nothing happens. A **function prop** such as `onDelete: () => Promise<void>` solves the first: the child calls `await props.onDelete()`, shows pending state, and can check whether a handler was passed at all. Keep events as the default for notifications the child does not wait on; use a callback prop when the child's behaviour depends on the parent's answer. For the unmount case, keep the confirming component mounted until the flow finishes, or run the flow in a component that outlives the menu.
code
vue · 33 lines<!-- DeleteButton.vue -->
<script setup lang="ts">
import { ref } from 'vue'
const props = defineProps<{
itemId: string
onDelete: (id: string) => Promise<void>
}>()
const pending = ref(false)
const failed = ref(false)
async function onClick() {
if (!window.confirm('Delete this item?')) return
pending.value = true
failed.value = false
try {
await props.onDelete(props.itemId)
} catch {
failed.value = true
} finally {
pending.value = false
}
}
</script>
<template>
<button type="button" :disabled="pending" @click="onClick">
{{ pending ? 'Deleting' : failed ? 'Retry delete' : 'Delete' }}
</button>
</template>
<!-- Parent: <DeleteButton :item-id="order.id" :on-delete="removeOrder" /> -->go deeper
Recall that emit notifies the parent but gives the child nothing back, and that props can also carry functions.
Explain that emit discards return values and skips unmounted instances, and how a function prop lets the child await a result.
Diagnose the silent no-op from component lifecycle, restructure so the confirming component outlives the flow, and pick events or callbacks per need.
Write a library guideline on when components expose events versus function props, and forbid declaring both for one action.
## The scenario A `<DeleteButton>` sits inside a row's dropdown menu: - clicking it opens a confirmation dialog and `await`s the answer - after confirmation it calls `emit('delete', itemId)` - the parent's handler calls an API that takes a second or two Two complaints arrive: sometimes confirming does nothing at all, and the button cannot show a spinner or an error while the deletion runs. ## Limit 1: emit is fire-and-forget `emit()` looks up the parent's listener and calls it, but **discards its return value**. Even if the handler is `async` and returns a promise, the child has nothing to `await`. Any pending state has to be managed by the parent and passed back down as a prop, which splits one interaction across two components. ## Limit 2: an unmounted component emits into nothing Vue's `emit` starts with a guard: if the component instance has been **unmounted**, it returns immediately. In the dropdown case the menu closes when focus moves to the dialog, the menu's `v-if` removes `<DeleteButton>`, and by the time the user confirms, the instance is gone. The `emit` call is silently ignored, with no warning. ## The callback prop alternative A component can declare a **function prop** instead of, or next to, an event: ```vue <script setup lang="ts"> const props = defineProps<{ itemId: string; onDelete: (id: string) => Promise<void> }>() </script> ``` The child now calls `await props.onDelete(props.itemId)` and owns its pending and error state. Because a parent's `@delete` compiles to the same `onDelete` key, the parent can pass it with `@delete="removeOrder"` or with `:on-delete="removeOrder"`. | Need | Emitted event | Callback prop | |---|---|---| | notify the parent, no answer needed | natural fit | works, but heavier | | await the parent's async result | not possible, return value discarded | `await props.onDelete()` | | know whether anyone is listening | not exposed as a declared value | `if (props.onDelete)` | | appears in the component's interface as | an event in `defineEmits` | a prop in `defineProps` | ## Fixing the unmount case A callback prop does **not** fix limit 2 on its own: if the component that holds the prop unmounts, its code after `await` still runs (a closure keeps it alive) but any state it sets goes nowhere. The reliable fixes are structural: 1. keep the menu, or at least the confirming component, mounted until the flow finishes 2. move the confirmation flow to a component that outlives the menu, such as the row or the page 3. let the parent open the dialog, so the button only emits a request and never waits ## Testing the flow Both failure modes are easy to cover in component tests: - **pending state**: pass an `onDelete` that returns a promise you resolve manually, click, assert the button is disabled and shows the pending label, then resolve and assert it is enabled again - **error state**: pass an `onDelete` that rejects and assert the retry label appears - **unmount mid-flow**: in the parent's test, open the confirmation, remove the menu, confirm, and assert what should happen, whether the deletion still runs or the flow is cancelled The third test is the one that documents the design decision. Without it, a later refactor that closes the menu earlier can reintroduce the silent no-op. ## Choosing in practice - Default to **events** for notifications: `confirm`, `cancel`, `select`. They keep the parent in charge and read clearly in templates. - Reach for a **callback prop** when the child needs the parent's **answer**: a promise for pending state, a boolean for validation, a value to display. - Do not declare both `delete` in `defineEmits` and an `onDelete` prop on the same component; pick one contract so consumers know which to use. - Whichever you choose, test the flow with the component being removed mid-way, because that is where the silent failure hides.
- Why does a Vue emit called after the component unmounted not warn?emit() checks whether the instance is unmounted and returns before looking up listeners or running validation. Vue treats it as a normal no-op, so the only symptom is that the parent's handler never runs.
- Can a Vue parent still write @delete when the child declares an onDelete function prop?Yes. The compiler turns `@delete` on a component into the `onDelete` key, and that key matches the declared prop, so the function arrives as `props.onDelete`. Writing `:on-delete` makes the intent clearer to readers.
- When is an emitted event still the better choice for a delete button?When the parent owns the whole flow: the button emits a request, the parent opens the dialog, calls the API and passes pending state back as a prop. The button then never waits and never outlives its menu.
saying these in an interview costs you the question
- await emit('delete') waits for the parent's async handler to finish.
- Vue queues an emit from an unmounted component and delivers it later.
- Callback props are forbidden in Vue; events are the only way to talk upward.
- Switching to a callback prop fixes the unmounted-menu bug by itself.
- Emitting after unmount throws an error the parent can catch.