How to efficiently pass data between components?
Props and emit - Vue's built-in system for parent-child communication: props carry data down from parent to child, emit sends events back up from child to parent.
Theory
TL;DR
- Props = data flowing down, read-only in the child. Emit = events flowing up, handled by the parent.
- Think of props as a config the branch office receives from HQ. Emit is the reply envelope sent back.
- Mutating props directly in a child breaks reactivity and triggers Vue warnings. Always emit instead.
- More than 2 levels deep, or sibling components? Use Pinia rather than chaining props.
v-modelis shorthand for props + emit combined::modelValuedown,update:modelValueup.
Quick Example
<!-- Child.vue -->
<script setup>
const props = defineProps(['count']);
const emit = defineEmits(['update']);
const increment = () => emit('update', props.count + 1);
</script>
<template>
<button @click="increment">{{ props.count }}</button>
</template><!-- Parent.vue -->
<script setup>
import { ref } from 'vue';
const count = ref(0);
const handleUpdate = (newCount) => { count.value = newCount; };
</script>
<template>
<Child :count="count" @update="handleUpdate" />
</template>Child displays the parent's count. Click fires emit('update', newValue). Parent handler updates its ref. Child re-renders with the new value. That cycle is the whole pattern.
Key Difference
Props create a one-way reactive link from parent to child. When the parent's value changes, the child updates automatically. But the child cannot write back: props are read-only inside the child. This keeps data flow predictable and makes debugging straightforward.
Emit goes the other direction. The child fires a named event with a payload. The parent listens and decides what to do with it. The parent always controls its own state; the child only notifies.
When to Use
- Passing config or display data downward (theme, items list, user profile) →
props - Child action needs to change something in the parent (form submit, delete button, modal close) →
emit - Two-way sync like a custom input field →
v-model(:modelValueprop +update:modelValueemit) - Data needs to skip several levels of the component tree →
provide/inject - Sibling components or global app state → Pinia store
The practical rule: if you find yourself passing a prop through a component that does not actually use it, just to get the data deeper, that is prop drilling. At that point, reach for Pinia.
Comparison Table
| Aspect | Props | Emit |
|---|---|---|
| Direction | Parent to child | Child to parent |
| Mutability | Read-only in child | Triggers parent handler |
| Reactivity | Automatic on parent change | Manual via event payload |
| Declaration | defineProps(['name']) | defineEmits(['event-name']) |
| When to use | Config, display data, lists | Actions, callbacks, results |
How Vue Handles This Internally
Vue's compiler transforms <Child :count="count" @update="handleUpdate" /> into createVNode(Child, { count: count, onUpdate: handleUpdate }). Props become reactive getters on the child's proxy object. Any change in the parent triggers the child's render effect automatically.
Emits work differently. They are registered via defineEmits and dispatched during the child's patch cycle. The parent listener is just the onUpdate property on the vnode, not a real browser DOM event.
One thing I see trip up developers on their first async-heavy project: when you emit from inside an async function, the payload is a plain JavaScript object, not a reactive value. The parent must assign it to a ref to get reactivity back. It does not happen automatically.
Common Mistakes
Mutating a prop directly:
<script setup>
const props = defineProps(['count']);
// Wrong: Vue warns, nothing propagates to parent
props.count++;
</script>Props are readonly proxies in Vue 3. Writing to them does not update the parent and throws a runtime warning. Emit the change instead and let the parent handle it.
Skipping defineEmits declarations:
<script setup>
const emit = defineEmits(['update']);
// Wrong: 'typo-event' not declared, fires silently with no error
emit('typo-event', data);
</script>Without declaring an event in defineEmits, typos in event names pass silently. Add every event name you use. In Vue 3.3+ you can attach a validation function per event to catch bad payloads before they reach the parent.
Passing a function as a prop for callbacks:
<!-- Wrong -->
<Child :on-submit="handleSubmit" />This technically works if the child calls props.onSubmit() directly, but it breaks the unidirectional event model and makes the component API harder to read. Use event binding instead: @submit="handleSubmit".
Mutating nested object props:
<!-- Tricky -->
<Child :task="task" />
<!-- Inside Child: props.task.title = 'new' also changes the parent's object -->Objects pass by reference. A child modifying a nested property silently changes the parent's data. Wrap the prop with readonly() inside the child, or pass a spread copy: v-bind="{ ...task }".
Real-World Usage
- TodoMVC (official Vue example): props carry task items down to list rows; emit handles toggle and delete back up to the parent.
- Element Plus form inputs: every input uses
v-model, which compiles to:modelValue+emit('update:modelValue'). - Nuxt UI modals: props supply initial form data;
emit('close', result)returns the result to the caller. - Quasar QDialog: configured via props, submits form data with
emit('ok', formData). - Pinia in larger apps: when Header and Cart both need user data, a Pinia store replaces 3+ levels of prop drilling.
Follow-Up Questions
Q: What is prop drilling and how do you avoid it?
A: Passing props through intermediate components that do not use them, just to get data deeper in the tree. Avoid it with provide/inject for static dependencies, or Pinia for reactive shared state.
Q: How does v-model work under the hood in Vue 3?
A: It compiles to :modelValue="value" prop and @update:modelValue="value = $event" emit. You can have multiple v-model bindings on a single component using named arguments like v-model:title="form.title".
Q: What is the difference between $emit and defineEmits?
A: defineEmits is the <script setup> syntax; it declares events and generates TypeScript types. $emit is the runtime method available in Options API. In <script setup>, calling const emit = defineEmits([...]) gives you the same callable function.
Q: What happens if a child emits before the parent listener is ready?
A: The event is lost. Vue does not queue emits waiting for listeners. If timing matters, use nextTick or confirm the parent is mounted before the child fires.
Q: Can frequent emits, say 100 per second, cause performance problems?
A: The dispatch itself is cheap, O(1). The real bottleneck is what the parent does inside the handler. If every emit triggers a large re-render, that adds up. Batch updates with nextTick and profile with Vue DevTools flamegraph to find the actual cost.
Examples
Basic: Counter with child button
<!-- CounterButton.vue -->
<script setup>
const props = defineProps({ count: Number });
const emit = defineEmits(['increment']);
</script>
<template>
<button @click="emit('increment')">
Clicked {{ props.count }} times
</button>
</template><!-- App.vue -->
<script setup>
import { ref } from 'vue';
import CounterButton from './CounterButton.vue';
const clicks = ref(0);
</script>
<template>
<CounterButton :count="clicks" @increment="clicks++" />
</template>State lives in the parent. The child is a pure display component that fires events. This is the base pattern for almost every Vue UI component.
Intermediate: Task form that emits to parent list
<!-- TaskForm.vue -->
<script setup>
import { ref } from 'vue';
const emit = defineEmits(['add-task']);
const title = ref('');
const submit = () => {
if (!title.value.trim()) return;
emit('add-task', { id: Date.now(), title: title.value, done: false });
title.value = ''; // Reset local state after emit
};
</script>
<template>
<form @submit.prevent="submit">
<input v-model="title" placeholder="New task" />
<button type="submit">Add</button>
</form>
</template><!-- TaskList.vue -->
<script setup>
import { ref } from 'vue';
import TaskForm from './TaskForm.vue';
const tasks = ref([]);
</script>
<template>
<TaskForm @add-task="(task) => tasks.push(task)" />
<ul>
<li v-for="task in tasks" :key="task.id">{{ task.title }}</li>
</ul>
</template>The form owns its local title state. TaskList owns the array. Emit is the bridge between them. This is the same architecture used in TodoMVC, the official Vue demo app.
Advanced: Async emit and the reactivity gap
<!-- DataLoader.vue -->
<script setup>
const emit = defineEmits(['data-loaded']);
const load = async () => {
const data = await fetch('/api/tasks').then(r => r.json());
// data is a plain JS array, not reactive
emit('data-loaded', data);
};
</script>
<template>
<button @click="load">Load tasks</button>
</template><!-- Parent.vue -->
<script setup>
import { ref } from 'vue';
import DataLoader from './DataLoader.vue';
const tasks = ref([]);
const onDataLoaded = (data) => {
tasks.value = data; // Assigning to ref restores reactivity
};
</script>
<template>
<DataLoader @data-loaded="onDataLoaded" />
<ul>
<li v-for="task in tasks" :key="task.id">{{ task.title }}</li>
</ul>
</template>Emitted payloads are always plain JavaScript values. No reactive wrapper survives the event boundary. The parent is responsible for placing the data into a ref or reactive. Developers who expect emitted data to stay reactive automatically will get a list that refuses to update. It does not stay reactive.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.