Skip to content
ExponaBLOG
VUE.JS & NUXT · DAY 009 / 100

Props and emits: what every Vue & Nuxt developer should know

Component communication.

Expona Admin6 min read

Understanding Props and Emits in Vue and Nuxt

Component communication forms the backbone of any Vue or Nuxt application, and misunderstanding how data flows between parent and child components can lead to bugs that ripple through your entire codebase. Props and emits are the foundational patterns for this communication, and mastering them separates developers who build maintainable systems from those who chase side effects and state management nightmares. In this article, we'll explore what every Vue and Nuxt developer should know about props and emits, including modern TypeScript patterns that make your components safer and more predictable.

defineProps: Typed Props with TypeScript

When you need to pass data from a parent component to a child, props are your primary mechanism. The defineProps macro in Vue 3's Composition API lets you declare props with full type safety when using TypeScript.

Consider a product card component that displays a title and optional discount count. Rather than accepting any data shape, you define exactly what your component expects:

typescript

<script setup lang="ts">
const props = defineProps<{
  title: string
  count?: number
}>()

console.log(props.title)
</script>

<template>
  <div class="card">
    <h3>{{ props.title }}</h3>
    <p v-if="props.count">{{ props.count }} items remaining</p>
  </div>
</template>

The parent component passes these props straightforwardly:

typescript

<template>
  <ProductCard title="Summer Sale" :count="15" />
</template>

TypeScript will catch if you pass the wrong type or forget a required prop. For more complex scenarios, you can also use the object syntax with default values and validators:

typescript

const props = defineProps({
  title: {
    type: String,
    required: true
  },
  count: {
    type: Number,
    default: 0,
    validator: (value: number) => value >= 0
  }
})

In a Nuxt application, this pattern keeps your components explicit about their dependencies. When reviewing code, you immediately understand what data flows into each component without hunting through the template or parent files.

defineEmits: Typed Events to the Parent

Data flows down through props, but user interactions and state changes need to flow back up. That's where emits come in. A child component emits events, and the parent listens and responds.

Let's say we have an input component that should notify its parent when the user submits a form. We declare the event with defineEmits:

typescript

<script setup lang="ts">
const emit = defineEmits<{
  submit: [formData: { email: string; name: string }]
  cancel: []
}>()

const formData = ref({ email: '', name: '' })

const handleSubmit = () => {
  emit('submit', formData.value)
  formData.value = { email: '', name: '' }
}

const handleCancel = () => {
  emit('cancel')
}
</script>

<template>
  <form @submit.prevent="handleSubmit">
    <input v-model="formData.email" type="email" />
    <input v-model="formData.name" type="text" />
    <button type="submit">Submit</button>
    <button type="button" @click="handleCancel">Cancel</button>
  </form>
</template>

The parent component listens for these events and reacts:

typescript

<script setup lang="ts">
const handleFormSubmit = (data: { email: string; name: string }) => {
  console.log('Form submitted:', data)
  // Send to API
}

const handleFormCancel = () => {
  console.log('Form cancelled')
}
</script>

<template>
  <UserFormModal
    @submit="handleFormSubmit"
    @cancel="handleFormCancel"
  />
</template>

TypeScript ensures the parent passes handlers with the correct signature. In real applications like ride-hailing platforms, this pattern keeps booking flows predictable: a ride request card emits book-ride or cancel-request events, and the orchestrating component decides what happens next.

One-Way Data Flow: Never Mutate Props, Emit Instead

Vue's unidirectional data flow rule is straightforward but critical: child components must never mutate props directly. This prevents confusion about where state actually lives and makes debugging far easier.

Here's what not to do:

typescript

<script setup lang="ts">
const props = defineProps<{ count: number }>()

// BAD: Mutating a prop
const increment = () => {
  props.count++
}
</script>

Instead, emit an event and let the parent manage state:

typescript

<script setup lang="ts">
const props = defineProps<{ count: number }>()
const emit = defineEmits<{
  'update:count': [newValue: number]
}>()

const increment = () => {
  emit('update:count', props.count + 1)
}
</script>

<template>
  <div>
    <p>Count: {{ props.count }}</p>
    <button @click="increment">Increment</button>
  </div>
</template>

The parent then listens and updates its own state:

typescript

<script setup lang="ts">
const count = ref(0)
</script>

<template>
  <Counter :count="count" @update:count="count = $event" />
</template>

This pattern is especially important in team environments. When you're reviewing a feature branch in an e-commerce platform, you can trace exactly how checkout state changes without searching through nested component props. The parent always owns the source of truth.

defineModel: Simplified Two-Way Binding for Custom Inputs

Vue 3.4 introduced defineModel, which simplifies the prop + emit + update:prop pattern. For custom form components, this is a game-changer.

Previously, you'd write:

typescript

<script setup lang="ts">
const props = defineProps<{ modelValue: string }>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
</script>

<template>
  <input
    :value="props.modelValue"
    @input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
  />
</template>

With defineModel, it becomes much cleaner:

typescript

<script setup lang="ts">
const model = defineModel<string>()
</script>

<template>
  <input v-model="model" />
</template>

The parent uses standard v-model binding:

typescript

<script setup lang="ts">
const email = ref('')
</script>

<template>
  <CustomEmailInput v-model="email" />
</template>

You can also create multiple models or support modifiers:

typescript

<script setup lang="ts">
const model = defineModel<string>()
const active = defineModel<boolean>('active')
</script>

<template>
  <input v-model="model" />
  <toggle v-model="active" />
</template>

In Nuxt applications, defineModel works beautifully with form components built for data capture, whether you're collecting search filters or building a multi-step wizard.

Common Mistakes

  • Mutating nested objects in props: Even though you can't reassign a prop, mutating deeply nested objects bypasses Vue's tracking. If you receive a user object via props and modify props.user.name = 'New Name', the parent's state changes without emitting an event. Create a new object and emit instead.
  • Forgetting to type emits: Without typed emits, typos in event names silently break communication. Use TypeScript's defineEmits to catch errors at development time, not in production.
  • Overusing emits for frequent updates: If a child component emits hundreds of events per second (like during a drag interaction), consider using a ref or composable instead. Emits are ideal for discrete user actions, not continuous data streaming.
  • Bidirectional prop binding patterns: Avoid creating props that the child modifies, then syncs back. Use defineModel for true two-way binding or stick to one-way data flow with explicit emits.
  • Passing functions via props: While technically possible, passing handler functions from parent to child blurs responsibility. Use emits instead so the child describes what happened, and the parent decides what to do.

Key Takeaways

  • Use defineProps<T>() with TypeScript for compile-time safety and self-documenting component contracts.
  • Declare emitted events with defineEmits to catch event name typos and ensure type-safe payloads.
  • Honor one-way data flow: parents pass data down via props, children emit events up. Never mutate props in a child component.
  • Reach for defineModel when building custom form inputs and components that benefit from v-model binding.
  • In team settings, this discipline makes code reviews faster and reduces state management bugs across your Nuxt or Vue application.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles