Skip to main content

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/inject for deep component trees
  • defineEmits in <script setup> declares events and enables TypeScript validation
  • v-model on a component compiles to :modelValue + @update:modelValue under the hood

Quick example

vue
<!-- Child.vue --> <script setup> const emit = defineEmits(['count-up']) const handleClick = () => emit('count-up', 1) </script> <template> <button @click="handleClick">+1</button> </template>
vue
<!-- 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 save with 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/inject or a store first

TypeScript declarations

In TypeScript projects, declare exact payload types with the generic syntax:

vue
<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:

vue
<!-- 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>
vue
<!-- 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:

vue
<!-- UserForm.vue --> <script setup> defineProps<{ firstName: string; lastName: string }>() defineEmits<{ 'update:firstName': [value: string] 'update:lastName': [value: string] }>() </script>
vue
<!-- 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:

vue
// 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:

vue
// 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.

vue
<!-- 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:

vue
// 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:

ts
// Declared as camelCase defineEmits(['updateUser']) // Template convention is kebab-case // @update-user won't reliably match 'updateUser' in all cases

Stick to kebab-case or the update:propName pattern consistently.

Real-world usage

  • Vuetify v-text-field emits update:modelValue on each keystroke for two-way binding
  • Element Plus el-table emits selection-change when the user selects rows
  • Nuxt page components use child modals that emit close to control overlay visibility
  • Custom form components emit submit with validated data to parent pages
  • Pinia integration: components emit filter-update which 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:

vue
<!-- 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>
vue
<!-- 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:

vue
<!-- 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>
vue
<!-- 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:

vue
<!-- 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>
vue
<!-- 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 ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?