Props & Emits
Passing data down and events up between components.
Components only become useful when they can talk to each other. Vue's rule is simple: props go down, events go up. A parent passes data into a child through props. The child never changes that data directly. It emits an event and lets the parent decide what to do.
This lesson builds the Task board's TaskList → TaskItem relationship, covers prop declaration and validation, typed props with TypeScript, emitting events, two-way binding on your own components with defineModel, and passing template content in with slots.
One-way data flow
Why so strict? If any child could change shared data, then when a value is wrong you'd have to check every component that touches it. With one-way flow, the state's owner is the only place it can change, so the data flow is predictable and easy to debug in Vue DevTools.
Props are read-only. props.task.title = 'x' won't throw for nested objects, because the object itself is still reactive, but it breaks one-way flow and makes bugs hard to trace. Reassigning props.title = 'x' triggers a runtime warning. Emit an event instead, or copy the prop into local state if the child really needs its own editable version.
Declaring props
With <script setup>, use the defineProps compiler macro. It needs no import:
<!-- TaskItem.vue -->
<script setup>
const props = defineProps({
task: {
type: Object,
required: true,
},
showPriority: {
type: Boolean,
default: true,
},
priorityLabels: {
type: Object,
// Object/array defaults must come from a factory function
default: () => ({ low: 'Low', normal: 'Normal', high: 'High' }),
},
density: {
type: String,
default: 'comfortable',
validator: (value) => ['compact', 'comfortable'].includes(value),
},
});
</script>
<template>
<li :class="['task', `task--${density}`]">
<span>{{ task.title }}</span>
<small v-if="showPriority">{{ priorityLabels[task.priority] }}</small>
</li>
</template>
Passing props from the parent:
<!-- Illustrative: ways to pass props -->
<!-- Static string -->
<TaskItem density="compact" />
<!-- Anything else needs v-bind, or it's passed as a string -->
<TaskItem :task="task" :show-priority="false" />
<!-- Boolean shorthand: presence means true -->
<TaskItem :task="task" show-priority />
<!-- Spread an object's properties as individual props -->
<TaskItem v-bind="taskProps" />
Declare props in camelCase in JavaScript (showPriority) and pass them in kebab-case in templates (show-priority). Vue converts between the two. Prop validation (type, required, validator) only produces console warnings in development builds. It's a developer aid, not runtime security.
Reactive props destructure (Vue 3.5)
In Vue 3.5+, destructured props stay reactive and you can use native default values:
// Illustrative: Vue 3.5+ only (the demos on this page run Vue 3.4)
const { task, showPriority = true, density = 'comfortable' } = defineProps(['task', 'showPriority', 'density']);
In 3.4 and earlier, destructuring defineProps() loses reactivity, so access props as props.task instead (or use toRefs(props)).
Typed props with TypeScript
With lang="ts" you can declare props using pure types. The compiler generates the runtime declaration for you:
<!-- Illustrative: TypeScript needs a build step -->
<script setup lang="ts">
export interface Task {
id: number;
title: string;
done: boolean;
priority: 'low' | 'normal' | 'high';
}
const props = withDefaults(defineProps<{
task: Task;
showPriority?: boolean;
density?: 'compact' | 'comfortable';
}>(), {
showPriority: true,
density: 'comfortable',
});
</script>
Now <TaskItem :task="{ title: 'x' }" /> is flagged in your editor (missing id, done, priority), and density="spacious" is a type error.
Emitting events
A child declares the events it can emit with defineEmits, then calls emit(name, ...payload):
<!-- TaskItem.vue -->
<script setup>
const props = defineProps({ task: { type: Object, required: true } });
const emit = defineEmits({
toggle: (id) => typeof id === 'number', // optional payload validation
remove: null, // no validation
rename: (id, title) => title.trim().length > 0,
});
function rename() {
// window globals like prompt() aren't reachable from templates, so call it here
const title = window.prompt('New title', props.task.title);
if (title) emit('rename', props.task.id, title);
}
</script>
<template>
<li>
<input type="checkbox" :checked="task.done" @change="emit('toggle', task.id)">
<span @dblclick="rename">{{ task.title }}</span>
<button @click="emit('remove', task.id)">×</button>
</li>
</template>
The parent listens with @event (kebab-case for multi-word names like @status-changed) and owns the state change:
<!-- TaskList.vue -->
<script setup>
import { ref, computed } from 'vue';
import TaskItem from './TaskItem.vue';
const tasks = ref([
{ id: 1, title: 'Design schema', done: true, priority: 'normal' },
{ id: 2, title: 'Build API endpoints', done: false, priority: 'high' },
{ id: 3, title: 'Write feature tests', done: false, priority: 'normal' },
]);
const remaining = computed(() => tasks.value.filter(t => !t.done).length);
function toggle(id) {
const task = tasks.value.find(t => t.id === id);
task.done = !task.done;
}
function remove(id) {
tasks.value = tasks.value.filter(t => t.id !== id);
}
function rename(id, title) {
tasks.value.find(t => t.id === id).title = title;
}
</script>
<template>
<p>{{ remaining }} of {{ tasks.length }} remaining</p>
<ul>
<TaskItem
v-for="task in tasks"
:key="task.id"
:task="task"
@toggle="toggle"
@remove="remove"
@rename="rename"
/>
</ul>
</template>
Here it is running. The demo's TaskItem combines the props from the previous section (showPriority, priorityLabels, density with its validator) with these emits, and the controls change the props the parent passes down. Tick, double-click a title to rename it, or remove a task. The event log shows each emit arriving at the parent:
TypeScript users can type the payloads too:
// Illustrative: TypeScript-only syntax
const emit = defineEmits<{
toggle: [id: number];
rename: [id: number, title: string];
remove: [id: number];
}>();
Emit an id, don't mutate the prop. Emitting the id instead of letting the child change task.done keeps the parent the single source of truth. The parent can then add an optimistic update, an API call to Laravel, an undo stack or analytics in one place, and every child gets the behaviour for free. It also makes TaskItem a pure presentational component that's trivial to test: mount it with a prop, click, check that the event was emitted.
v-model on your own components
For form-like components, emitting an "update" event for every change gets repetitive. v-model on a component is shorthand for a prop plus an update event:
<!-- Illustrative: what v-model on a component expands to -->
<PrioritySelect v-model="task.priority" />
<!-- expands to -->
<PrioritySelect
:modelValue="task.priority"
@update:modelValue="value => task.priority = value"
/>
Since Vue 3.4, the child side is a one-liner with defineModel():
<!-- PrioritySelect.vue -->
<script setup>
const priority = defineModel({ type: String, default: 'normal' });
const options = ['low', 'normal', 'high'];
</script>
<template>
<div class="segmented">
<button
v-for="option in options"
:key="option"
type="button"
:class="{ active: priority === option }"
@click="priority = option"
>
{{ option }}
</button>
</div>
</template>
defineModel() is a compiler macro, so it only works in SFCs. The live demos on this page are compiled in the browser, so they use the expanded modelValue prop + update:modelValue emit form shown above. The behaviour is identical. defineModel() returns a ref. Reading it reads the prop, and assigning to it emits update:modelValue. The parent still owns the state, so one-way flow is preserved. You just write less code.
Multiple and named models
A component can expose several v-models by naming them:
<!-- TaskEditor.vue -->
<script setup>
const title = defineModel('title', { required: true });
const priority = defineModel('priority', { default: 'normal' });
</script>
<!-- Parent -->
<TaskEditor v-model:title="draft.title" v-model:priority="draft.priority" />
Both custom models in action. The parent's draft object is printed underneath:
Before 3.4 you'd write the expansion by hand: defineProps(['modelValue']), defineEmits(['update:modelValue']), then emit on change. You'll still see this pattern in existing codebases, and it's exactly what defineModel compiles to.
Slot basics
Props pass data into a component. Slots pass template content. A slot is a placeholder in the child's template that the parent fills, which is how cards, modals, buttons and layouts accept arbitrary markup:
<!-- BaseCard.vue -->
<template>
<section class="card">
<header>
<slot name="header">Untitled</slot> <!-- fallback if the parent passes nothing -->
</header>
<slot /> <!-- the default slot -->
<footer v-if="$slots.footer">
<slot name="footer" />
</footer>
</section>
</template>
<!-- Parent -->
<BaseCard>
<template #header>Sprint 12</template>
<p>8 tasks · 3 done</p>
<template #footer>
<button @click="openSprint">Open board</button>
</template>
</BaseCard>
#headeris shorthand forv-slot:header. Content outside any<template #…>goes into the default slot.- Text between
<slot>tags is fallback content, shown when the parent provides nothing. $slots.footer(oruseSlots()in<script setup>) checks whether a slot was provided, so the child doesn't render an empty footer.- Slot content is compiled in the parent's scope:
openSprintis the parent's function, not the card's.
Passing data back from the child to the slot content (scoped slots) is covered in Slots, Provide/Inject & Render Functions.
Fallthrough attributes
Attributes you pass that aren't declared as props or emits, such as class, style, id, data-*, aria-* and native listeners like @click, fall through to the child's root element automatically:
<!-- Parent -->
<BaseButton class="w-full" aria-label="Save task" @click="save">Save</BaseButton>
<!-- BaseButton.vue: class, aria-label and the click listener land on <button> -->
<template>
<button class="btn"><slot /></button>
</template>
class and style are merged with the root's own values. To put them somewhere other than the root, disable inheritance and bind $attrs yourself:
<!-- BaseField.vue -->
<script setup>
defineOptions({ inheritAttrs: false });
</script>
<template>
<label class="field">
<span><slot name="label" /></span>
<input v-bind="$attrs"> <!-- placeholder, type, @input… go to the input -->
</label>
</template>
Both components running. Inspect the elements in DevTools to see where the attributes landed:
<!-- Parent used by the demo -->
<BaseButton class="w-full" aria-label="Save task" @click="save">Save</BaseButton>
<!-- In Vue 3, $attrs includes class and style, so class lands on the <input> too -->
<BaseField type="email" placeholder="you@example.com" class="field-input" @input="email = $event.target.value">
<template #label>Email</template>
</BaseField>
Reusable building blocks
Once props and emits are in place, components become reusable building blocks. The star rating below is one component used three times with different props, and each instance reports changes back to the shared parent through v-model:
<!-- Parent -->
<StarRating v-model="ratings.clarity" />
<StarRating v-model="ratings.difficulty" :max="3" />
<StarRating :model-value="Math.round(average)" readonly />
<!-- StarRating.vue -->
<script setup>
const rating = defineModel({ type: Number, default: 0 });
defineProps({
max: { type: Number, default: 5 },
readonly: Boolean,
});
</script>
<template>
<span class="stars">
<button
v-for="n in max"
:key="n"
:disabled="readonly"
:aria-label="`${n} stars`"
@click="rating = n"
>{{ n <= rating ? '★' : '☆' }}</button>
</span>
</template>
Key takeaways
- Props down, events up. The state's owner is the only place it changes, which keeps data flow predictable.
- Declare props with
defineProps(runtime object or TypeScript types). Use factory functions for object and array defaults. Validation only warns in development. - Children emit with
defineEmits+emit('name', payload). Parents listen with@nameand update their own state. defineModel()(Vue 3.4+) makes customv-modela one-liner, and named models (v-model:title) allow several per component.- Slots pass template content into a component: default, named (
#header) and fallback content, with$slotsto check what was provided. - Undeclared attributes fall through to the root element. Use
inheritAttrs: false+v-bind="$attrs"to redirect them.