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:
<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:
<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:
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:
<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:
<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:
<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:
<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:
<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:
<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:
<script setup lang="ts">
const model = defineModel<string>()
</script>
<template>
<input v-model="model" />
</template>
The parent uses standard v-model binding:
<script setup lang="ts">
const email = ref('')
</script>
<template>
<CustomEmailInput v-model="email" />
</template>
You can also create multiple models or support modifiers:
<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
defineEmitsto 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
defineModelfor 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
defineEmitsto 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
defineModelwhen building custom form inputs and components that benefit fromv-modelbinding. - In team settings, this discipline makes code reviews faster and reduces state management bugs across your Nuxt or Vue application.






Comments
Loading comments…