Skip to main content

🔗 Props, Events, and Communication

Components are only powerful once they can talk to one another. Vue gives you a clean, predictable model: data flows down through props, notifications flow up through events, and content flows in through slots. Master this trio and you can wire together interfaces of any size without tangling your components.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Declare and validate props with types, defaults, requirements, and custom validators using defineProps
  • Explain one-way data flow and correctly handle a prop you need to transform or copy
  • Emit and listen for custom events with defineEmits and $emit
  • Build a two-way binding on a custom component with v-model
  • Distribute content into a child with default, named, and scoped slots

Estimated Time: 40–50 minutes  •  Difficulty: Intermediate

Hands-on: Build a StarRating component that works with v-model.

In This Lesson

The Communication Model

Vue enforces a deliberately simple rule for parent-child communication: props go down, events go up. The parent hands data to the child through props; the child asks the parent to change something by emitting an event. Neither side reaches into the other's internals.

flowchart TD A[Parent Component] -->|Props: data down| B[Child Component] B -->|Events: messages up| A A -->|Provide / Inject| C[Deeply Nested Child] D[Pinia Store] -.shared state.-> A D -.-> B D -.-> C

The core flow (props down, events up), plus the escape hatches for deeper communication.

💡 A useful analogy: Think of a company. A manager (parent) assigns tasks and information to an employee (child) — that's props flowing down. The employee files reports and requests back up to the manager — that's events. The employee never rewrites the manager's plans directly; they send a report and let the manager decide. For company-wide policy that everyone needs, there's a shared handbook (a Pinia store).

This lesson focuses on the two everyday tools — props and events — plus slots for passing markup, and touches on provide/inject for the occasional deep hand-off.

Props: Data Down

A prop (short for "property") is a custom attribute a parent sets on a child. In <script setup> you declare props with the defineProps compiler macro — no import needed.

<!-- BlogPost.vue -->
<template>
  <article>
    <h2>{{ title }}</h2>
    <p>{{ likes }} likes</p>
  </article>
</template>

<script setup>
// Object syntax lets you specify a type per prop
const props = defineProps({
  title: String,
  likes: Number
})
</script>

The parent passes props like HTML attributes. Use a plain attribute for a literal string, and v-bind (shorthand :) to pass a JavaScript expression:

<!-- A static string -->
<BlogPost title="My Journey with Vue" :likes="0" />

<!-- Dynamic values from parent state -->
<BlogPost :title="post.title" :likes="post.likes" />

<!-- Spread an object to pass many props at once -->
<BlogPost v-bind="post" />

📖 Naming: camelCase vs. kebab-case

Declare props in camelCase in JavaScript (postTitle) and pass them in kebab-case in templates (post-title). Vue maps between the two automatically, matching HTML's case-insensitive attribute convention.

Prop Validation

Beyond a bare type, the object syntax lets you mark a prop required, give it a default, and even run a custom validator. Validation catches bugs early in development and doubles as living documentation of how the component expects to be used.

const props = defineProps({
  // Basic type check
  username: String,

  // Multiple allowed types
  id: [String, Number],

  // Required prop — Vue warns if it's missing
  author: {
    type: Object,
    required: true
  },

  // Default value for a simple type
  status: {
    type: String,
    default: 'draft'
  },

  // Objects/arrays need a FACTORY function for their default
  tags: {
    type: Array,
    default: () => []
  },

  // Custom validator: return true if the value is acceptable
  priority: {
    type: Number,
    validator: (value) => value >= 1 && value <= 5
  }
})

⚠️ Watch the default for objects and arrays

The default for an Object or Array prop must be returned from a factory function (default: () => []), not written inline (default: []). Otherwise every instance of the component would share one and the same array — mutating it in one place would surprise you everywhere else.

If you use TypeScript, you can declare props with a type annotation instead, and Vue derives the runtime checks for you:

<script setup lang="ts">
interface Props {
  title: string
  likes?: number          // optional
  author: { name: string; email: string }
}

const props = defineProps<Props>()
</script>

One-Way Data Flow

Props form a one-way, top-down binding: when the parent's value changes, it flows into the child, but the child must not write back to the prop. This keeps data flow easy to follow — you always know a value's owner. Vue will warn you in the console if you mutate a prop directly.

Two situations tempt you to mutate a prop. Here's the right way to handle each:

1. Using a prop as an initial value

<script setup>
import { ref } from 'vue'

const props = defineProps(['initialCount'])

// ✅ Copy it into local state you own and are free to change
const count = ref(props.initialCount)
</script>

2. Transforming a prop

<script setup>
import { computed } from 'vue'

const props = defineProps(['size'])

// ✅ Derive a new value with computed — the prop stays untouched
const normalizedSize = computed(() => props.size.trim().toLowerCase())
</script>

✅ The rule of thumb

Treat every prop as read-only. If you need a different value, either copy it into a ref, derive it with computed, or emit an event asking the parent to make the change. Never assign to a prop.

Events: Messages Up

When a child needs to tell its parent something happened, it emits an event. Declare the events a component can emit with defineEmits, then call the returned function with the event name and an optional payload.

<!-- CounterButton.vue -->
<template>
  <button @click="onClick">Clicked {{ count }} times</button>
</template>

<script setup>
import { ref } from 'vue'

const emit = defineEmits(['increment'])
const count = ref(0)

function onClick() {
  count.value++
  // Notify the parent, passing the new count as the payload
  emit('increment', count.value)
}
</script>

The parent listens with v-on (shorthand @). The payload arrives as the handler's argument:

<template>
  <p>Total across all buttons: {{ total }}</p>
  <CounterButton @increment="handleIncrement" />
</template>

<script setup>
import { ref } from 'vue'
import CounterButton from './CounterButton.vue'

const total = ref(0)
function handleIncrement(newCount) {
  total.value = newCount
}
</script>

💡 Declaring events is worth it

Listing your events in defineEmits documents the component's public "output" contract, and the object form lets you validate payloads. With TypeScript you can type each event's payload precisely:

const emit = defineEmits<{
  (e: 'increment', value: number): void
  (e: 'submit', payload: { email: string; password: string }): void
}>()

v-model on Components

You already know v-model for form inputs. On a custom component it's simply sugar for a prop plus an event. In Vue 3.4+, the ergonomic way to support it is the defineModel macro, which gives you a writable ref that stays in sync with the parent automatically.

<!-- CustomInput.vue (Vue 3.4+) -->
<template>
  <input v-model="model" />
</template>

<script setup>
// One line: creates the prop + update event and returns a writable ref
const model = defineModel()
</script>
<!-- Parent -->
<CustomInput v-model="searchText" />

Under the hood, <CustomInput v-model="searchText" /> expands to a modelValue prop and an update:modelValue event. If you're on an older Vue or want to see the mechanics, here's the explicit version:

<!-- CustomInput.vue (explicit form) -->
<template>
  <input
    :value="modelValue"
    @input="emit('update:modelValue', $event.target.value)"
  />
</template>

<script setup>
defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])
</script>

Named and multiple bindings

A component can expose more than one v-model by naming them. defineModel('firstName') pairs with v-model:first-name on the parent:

<!-- NameFields.vue -->
<script setup>
const firstName = defineModel('firstName')
const lastName = defineModel('lastName')
</script>

<!-- Parent -->
<NameFields
  v-model:first-name="user.firstName"
  v-model:last-name="user.lastName"
/>

Slots: Content In

Props pass data; slots pass markup. A slot is a placeholder inside a child where the parent injects its own template content. This is how you build flexible container components like cards, layouts, and lists.

Default slot

<!-- BaseCard.vue -->
<template>
  <div class="card">
    <h3>{{ title }}</h3>
    <!-- Parent content lands here; the text is fallback -->
    <slot>Nothing here yet.</slot>
  </div>
</template>

<!-- Parent -->
<BaseCard title="Profile">
  <p>Name: Ada Lovelace</p>
  <p>Role: Engineer</p>
</BaseCard>

Named slots

Multiple slots need names. Define them with <slot name="header"> and fill them with <template #header>:

<!-- PageLayout.vue -->
<template>
  <header><slot name="header" /></header>
  <main><slot /></main>
  <footer><slot name="footer" /></footer>
</template>

<!-- Parent -->
<PageLayout>
  <template #header><h1>Dashboard</h1></template>

  <p>This goes in the default slot.</p>

  <template #footer><small>&copy; 2026</small></template>
</PageLayout>

Scoped slots

A scoped slot lets the child pass data back to the parent's slot content — perfect for a list component that owns the data but delegates how each row looks:

<!-- ItemList.vue -->
<template>
  <ul>
    <li v-for="(item, index) in items" :key="item.id">
      <!-- Expose item + index to the parent's template -->
      <slot :item="item" :index="index">{{ item.name }}</slot>
    </li>
  </ul>
</template>

<script setup>
defineProps({ items: { type: Array, required: true } })
</script>

<!-- Parent: destructure the slot props the child exposed -->
<ItemList :items="products">
  <template #default="{ item, index }">
    <strong>{{ index + 1 }}.</strong> {{ item.name }} — ${{ item.price }}
  </template>
</ItemList>

📖 A note on provide / inject

When data must reach a deeply nested descendant, threading props through every intermediate layer ("prop drilling") gets tedious. An ancestor can provide('theme', value) and any descendant can inject('theme'). Reserve this for genuinely tree-wide concerns like theme or auth state; for broad app state, a Pinia store (covered later) is the better tool.

Hands-on Exercise

🏋️ Build a StarRating That Works with v-model

Objective: Combine props, events, and v-model into one small, genuinely reusable input component.

Requirements:

  1. Create StarRating.vue that renders 5 clickable stars.
  2. Support v-model so the parent binds the current rating.
  3. Accept a max prop (Number, default 5) controlling how many stars render.
  4. Fill stars up to the current value; clicking a star sets that value.
  5. In a parent, bind it with v-model="score" and show "You rated: {{ score }}/5" live.
💡 Hint

Use const rating = defineModel() for the two-way binding and defineProps({ max: { type: Number, default: 5 } }) for the count. Render stars with v-for="n in max"; a star at position n is filled when n <= rating. On click, set rating.value = n.

✅ Solution
<!-- StarRating.vue -->
<template>
  <div class="star-rating">
    <button
      v-for="n in max"
      :key="n"
      type="button"
      class="star"
      :class="{ filled: n <= rating }"
      :aria-label="`Rate ${n} of ${max}`"
      @click="rating = n"
    >
      ★
    </button>
  </div>
</template>

<script setup>
const rating = defineModel({ default: 0 })
defineProps({
  max: { type: Number, default: 5 }
})
</script>

<style scoped>
.star { background: none; border: none; font-size: 1.6rem; cursor: pointer; color: #ccc; }
.star.filled { color: #f5b301; }
</style>
<!-- Parent -->
<template>
  <StarRating v-model="score" />
  <p>You rated: {{ score }}/5</p>
</template>

<script setup>
import { ref } from 'vue'
import StarRating from './StarRating.vue'
const score = ref(3)
</script>

Best Practices

✅ Do🚫 Don't
Treat props as read-only; copy or derive when you need to change themAssign directly to a prop inside the child
Return object/array prop defaults from a factory functionWrite default: [] inline (shared across instances)
Declare emitted events in defineEmits as a contractEmit undeclared events with vague names like 'change' everywhere
Use defineModel for two-way bindings on custom inputsHand-wire modelValue + update:modelValue when the macro will do
Reach for a store or provide/inject only when prop drilling truly hurtsUse provide/inject as a default substitute for clear props

🎯 Quick Quiz

Question 1: A child component needs a value it can edit, seeded from a startValue prop. What should it do?

Question 2: On a component, v-model="text" is shorthand for which prop-and-event pair?

Question 3: You want a list component to own its data but let the parent decide how each row looks. Which feature fits?

Summary & Quiz

🎉 Key Takeaways

  • Props down, events up: parents pass data via props; children request changes via emitted events.
  • Validate props with types, required, defaults (factory for objects/arrays), and validators.
  • Props are read-only — copy into a ref or derive with computed instead of mutating.
  • v-model on a component is a prop + event pair; defineModel is the modern way to support it.
  • Slots pass markup: default, named, and scoped slots build flexible container components.

📚 Further Reading

🚀 What's Next?

You've now seen ref, computed, and lifecycle hooks in passing. Next we go all-in on the Composition API Fundamentals — reactivity, watchers, template refs, and extracting reusable logic into composables.

🎉 Nice work!

Your components can now speak to one another cleanly. Time to master the reactivity behind them.