Emits and custom events in Vue.js
Emits - child Vue components use them to send custom events up to parent components, reversing the direction of props.
Theory
TL;DR
- A child component is like a submit button in a form: it does not know what the parent will do after the click, it just fires "submitted!"
- Props flow down (parent controls data); emits flow up (child triggers notifications)
- Use emits for child-to-parent communication; use Pinia for siblings; use
provide/injectfor deep component trees defineEmitsin<script setup>declares events and enables TypeScript validationv-modelon a component compiles to:modelValue+@update:modelValueunder the hood
Quick example
<!-- Child.vue -->
<script setup>
const emit = defineEmits(['count-up'])
const handleClick = () => emit('count-up', 1)
</script>
<template>
<button @click="handleClick">+1</button>
</template><!-- Parent.vue -->
<script setup>
import { ref } from 'vue'
const total = ref(0)
</script>
<template>
<Child @count-up="total += $event" />
<p>Total: {{ total }}</p>
</template>Click the button and total increments by 1 each time. The child never touches total directly. It fires a signal. The parent decides what to do with it.
Props down, events up
Props enforce one direction: parent sends data, child reads it. Emits go the other way. When a child needs the parent to act, it fires an event with a payload. The parent handles it. This keeps components decoupled - the same child component works with any parent that knows how to handle its events.
When to use emits
- Form submit in a child component, parent saves to API: emit
savewith form data - Modal close button, parent hides the overlay: emit
close - Table row selection, parent updates selection state: emit
selection-change - Two siblings need to share state: use Pinia, not emits
- Deeply nested component needs to update root state: consider
provide/injector a store first
TypeScript declarations
In TypeScript projects, declare exact payload types with the generic syntax:
<script setup lang="ts">
const emit = defineEmits<{
save: [profile: { name: string; avatar: string }]
cancel: []
search: [query: string, page: number]
}>()
emit('save', { name: 'Alice', avatar: '/img.jpg' }) // type-checked
emit('save', 'wrong') // type error
emit('cancel') // no payload needed
</script>The IDE catches wrong argument types while you write, not at runtime. For simpler codebases, the array syntax defineEmits(['save', 'cancel']) still beats nothing.
v-model under the hood
v-model on a component compiles to a prop plus emit pair. The prop is modelValue, the emit is update:modelValue:
<!-- CustomInput.vue -->
<script setup>
defineProps<{ modelValue: string }>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
</script>
<template>
<input
:value="modelValue"
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
/>
</template><!-- Parent.vue -->
<template>
<CustomInput v-model="username" />
<!-- same as: <CustomInput :modelValue="username" @update:modelValue="username = $event" /> -->
</template>Multiple v-model bindings on one component are possible too. Each needs its own named prop and a matching update:propName emit:
<!-- UserForm.vue -->
<script setup>
defineProps<{ firstName: string; lastName: string }>()
defineEmits<{
'update:firstName': [value: string]
'update:lastName': [value: string]
}>()
</script><!-- Parent.vue -->
<template>
<UserForm v-model:firstName="first" v-model:lastName="last" />
</template>How Vue processes emits
When you call emit('save', payload), Vue looks for a listener declared as @save on the component's vnode. It passes payload as $event to the handler. There is no DOM event here - this is Vue's own event system, not addEventListener. Because of that, emits don't bubble up the component tree. An event fired from a grandchild doesn't reach the grandparent unless you relay it manually or route it through a store.
Common mistakes
Not declaring emits with defineEmits:
// Bad - Vue 3.3+ warns "Extraneous non-emits event listeners"
const emit = () => {} // no defineEmits
// Good
const emit = defineEmits(['save', 'cancel'])Without defineEmits, TypeScript validation is gone and dev warnings become noisy.
Mutating a prop directly in the child:
// Bad - Vue warns and the change reverts on next parent render
props.user.name = 'Bob'
// Good - emit the change, let the parent update
emit('update:user', { ...props.user, name: 'Bob' })Forgetting $event in an inline handler:
In my experience reviewing Vue code, this trips people up more than any other emit mistake. The event fires, the handler runs, but the payload disappears without a word.
<!-- Bad - payload is silently lost -->
<Child @save="console.log('saved')" />
<!-- Good - $event holds the payload -->
<Child @save="handleSave($event)" />
<!-- or pass the function reference directly -->
<Child @save="handleSave" />Emitting in a loop:
// Bad - fires N separate events, each can trigger a re-render
items.forEach(item => emit('add', item))
// Good - batch into one emit
emit('add-batch', items)Using camelCase event names:
// Declared as camelCase
defineEmits(['updateUser'])
// Template convention is kebab-case
// @update-user won't reliably match 'updateUser' in all casesStick to kebab-case or the update:propName pattern consistently.
Real-world usage
- Vuetify
v-text-fieldemitsupdate:modelValueon each keystroke for two-way binding - Element Plus
el-tableemitsselection-changewhen the user selects rows - Nuxt page components use child modals that emit
closeto control overlay visibility - Custom form components emit
submitwith validated data to parent pages - Pinia integration: components emit
filter-updatewhich parent handlers pass to store actions
Follow-up questions
Q: What is the difference between defineEmits in <script setup> and this.$emit in Options API?
A: defineEmits declares events at compile time and gives you TypeScript validation plus IDE autocomplete. this.$emit in Options API works at runtime only, with no type checking unless you add manual configuration on top.
Q: Do Vue emits bubble up the component tree like DOM events?
A: No. A Vue emit only reaches the direct parent. If a grandchild needs to notify the root, you relay the emit through each intermediate component or use a shared store.
Q: How does Vue 3.4's defineModel() relate to emits?
A: defineModel() is a compiler macro that automatically creates a prop plus emit pair. Under the hood it still emits update:modelValue, but it removes the boilerplate of writing both defineProps and defineEmits separately.
Q: In SSR, do emits work the same way as on the client?
A: Server-side rendering ignores emits - there is no DOM and no user interaction. Emits only matter on the client after hydration. If a parent handler has side effects that depend on being mounted, wrap them in onMounted.
Q: Can a child emit an event to a grandparent without manual relay?
A: Not with Vue emits directly. For ancestor communication use provide/inject. For anything crossing multiple component layers, a Pinia store is the cleaner option.
Examples
Basic: Counter button
A reusable button that lets the parent decide what happens to the count:
<!-- CounterButton.vue -->
<script setup>
const emit = defineEmits<{ increment: [amount: number] }>()
</script>
<template>
<button @click="emit('increment', 1)">+1</button>
<button @click="emit('increment', 10)">+10</button>
</template><!-- App.vue -->
<script setup>
import { ref } from 'vue'
import CounterButton from './CounterButton.vue'
const count = ref(0)
</script>
<template>
<CounterButton @increment="count += $event" />
<p>Count: {{ count }}</p>
</template>CounterButton holds no count state. It signals how much to add. The parent controls what that means.
Intermediate: Profile editor
An editable profile card where saves and cancellations go up to the dashboard:
<!-- ProfileCard.vue -->
<script setup lang="ts">
import { ref } from 'vue'
const emit = defineEmits<{
save: [profile: { name: string; avatar: string }]
cancel: []
}>()
const profile = ref({ name: 'Alice', avatar: '' })
</script>
<template>
<div>
<input v-model="profile.name" placeholder="Name" />
<input v-model="profile.avatar" placeholder="Avatar URL" />
<button @click="emit('save', profile)">Save</button>
<button @click="emit('cancel')">Cancel</button>
</div>
</template><!-- Dashboard.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import ProfileCard from './ProfileCard.vue'
const profiles = ref<{ name: string; avatar: string }[]>([])
const handleSave = (newProfile: { name: string; avatar: string }) => {
profiles.value.push(newProfile)
}
</script>
<template>
<ProfileCard
@save="handleSave"
@cancel="() => console.log('Cancelled')"
/>
<ul>
<li v-for="p in profiles" :key="p.name">{{ p.name }}</li>
</ul>
</template>Save appends to the list. Cancel logs a message. ProfileCard knows nothing about profiles - it reports what happened and moves on.
Advanced: Password confirmation with multiple v-models
Two inputs bound to different v-model props, each with its own emit:
<!-- PasswordFields.vue -->
<script setup lang="ts">
const props = defineProps<{
modelValue: string
confirmValue: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
'update:confirmValue': [value: string]
}>()
</script>
<template>
<div>
<input
:value="props.modelValue"
placeholder="Password"
type="password"
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
/>
<input
:value="props.confirmValue"
placeholder="Confirm password"
type="password"
@input="emit('update:confirmValue', ($event.target as HTMLInputElement).value)"
/>
</div>
</template><!-- SignupForm.vue -->
<script setup lang="ts">
import { ref, watch } from 'vue'
import PasswordFields from './PasswordFields.vue'
const password = ref('')
const confirm = ref('')
watch([password, confirm], ([p, c]) => {
if (p && c) console.log(p === c ? 'Passwords match' : 'No match')
})
</script>
<template>
<PasswordFields
v-model="password"
v-model:confirmValue="confirm"
/>
</template>Each field updates its own ref independently. The watcher catches mismatches as the user types. The tricky part here is the naming: v-model:confirmValue maps to the prop confirmValue and the emit update:confirmValue.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.