Chapter 9 of 11

Vue 3 tutorial – component events with defineEmits

verified on 2 September 2026 · 3 min

Quick answer

A child component tells its parent about a change by emitting an event: defineEmits declares it, emit('add-to-cart', id) sends it, and the parent listens for it with @add-to-cart. Props go down, events come up.

By the end of this chapter, you will know how to send information from a component back up to its parent.

Props come down from the parent to the child. To send something back up, a component emits an event and the parent listens for it, exactly as it would listen for a click.

The problem

Since the split in chapter 8, the cart lives in App.vue and the button in ProductDisplay.vue. The child cannot modify cart: it has no access to it, and a component has no business writing into another one’s state.

The right way to put it is not “the child adds to the cart”, but “the child announces that a click happened, and the parent decides what to do about it”.

Declaring and emitting

In the child, declare the events it emits, then emit:

src/components/ProductDisplay.vue
<script setup>
const emit = defineEmits(['add-to-cart'])

function addToCart() {
  emit('add-to-cart')
}
</script>

<template>
  <button
    class="button"
    :class="{ disabledButton: !inStock }"
    :disabled="!inStock"
    @click="addToCart"
  >
    Ajouter au panier
  </button>
</template>

defineEmits is a compiler macro, like defineProps: no import needed. It returns the function used to emit.

Declaring the events is not compulsory, but it is worth doing: the list documents the component’s contract, your editor autocompletes from it, and Vue warns you if you emit a name that was never declared.

Listening on the parent side

A custom event is listened for with the same syntax as a native one:

src/App.vue
<script setup>
import { ref } from 'vue'
import ProductDisplay from './components/ProductDisplay.vue'

const cart = ref(0)

function updateCart() {
  cart.value += 1
}
</script>

<template>
  <div class="cart">Panier ({{ cart }})</div>
  <ProductDisplay :premium="premium" @add-to-cart="updateCart" />
</template>

Click: the counter starts moving again. The button is in the child, the counter in the parent, and neither of them knows anything about the other’s state.

Passing data along

A realistic cart remembers what goes into it, not just how much. Pass the id of the variant as the second argument to emit:

src/components/ProductDisplay.vue
<script setup>
function addToCart() {
  emit('add-to-cart', variants.value[selectedVariant.value].id)
}
</script>

The parent receives it as a parameter of its function:

src/App.vue
<script setup>
const cart = ref([])

function updateCart(id) {
  cart.value.push(id)
}
</script>

<template>
  <div class="cart">Panier ({{ cart.length }})</div>
  <ProductDisplay :premium="premium" @add-to-cart="updateCart" />
</template>

cart has become an array, so the display uses cart.length. Add the blue T-shirt several times, then check in the Vue developer tools that the array really is filling up with 2234.

For several pieces of information, emit an object rather than a long list of arguments:

javascript
emit('add-to-cart', { id: variant.id, taille: 'M', quantite: 1 })

Naming events

Write the name in kebab-case: add-to-cart, not addToCart. HTML attributes are case-insensitive, a template written directly in a page would receive @addtocart and the listener would never fire. Inside a .vue file, Vue catches the case for you, but you may as well pick up the right habit.

Events do not travel up on their own

A custom event only reaches the direct parent. If three levels of components separate the sender from the recipient, it has to be re-emitted on every floor. When that starts to hurt, it is the signal that you need shared state: which is exactly what Pinia is for.

Which way things flow

A review that arrives along this route can then be animated as it appears.

Remember these two sentences: props go down, events come up. The parent owns the data, the child asks for it to be changed. That is what makes the flow predictable, and the origin of any change always traceable.

With the Options API

Events are declared in the emits option and sent with this.$emit('add-to-cart', id). The parent listens for them in exactly the same way.

ExerciseAdd a remove-from-cart event emitted by a second button, and have the parent drop the last id from the array.

Common errors

Naming the event in camelCase HTML attributes ignore case: a template outside a .vue file would receive @addtocart and the listener would never fire. Use kebab-case.
Expecting an event to cross several levels An event only reaches the direct parent. Past two levels, move to shared state with Pinia.
Changing the parent’s state from the child The child signals, the parent decides. That is what keeps the origin of changes traceable.
Newsletter

New tests, tutorials and projects, by e-mail.

Reproducible tests, versioned code, dated results. Never any spam.