Vue
02 / 08

Component Communication & Props

Vue Component Communication & Props

Learn how Vue components communicate with each other through props, events, provide/inject, and other patterns:

Props - Parent to Child Communication

Props allow parent components to pass data down to child components. They are reactive and can be validated with types and validators.

<script setup>
// Basic props
const props = defineProps({
  title: String,
  count: Number,
  user: Object,
  tags: Array,
})

// Props with defaults and validation
const props = defineProps({
  title: {
    type: String,
    required: true,
  },
  count: {
    type: Number,
    default: 0,
  },
  status: {
    type: String,
    default: 'pending',
    validator: (value) => ['pending', 'active', 'completed'].includes(value),
  },
  user: {
    type: Object,
    required: true,
    validator: (value) => value.id && value.name,
  },
})

// TypeScript props
interface Props {
  title: string
  count?: number
  user: { id: number; name: string }
}

const props = withDefaults(defineProps<Props>(), {
  count: 0,
})

// Destructuring props (loses reactivity)
const { title, count } = props // ❌ Not reactive

// Use toRefs to maintain reactivity
import { toRefs } from 'vue'
const { title, count } = toRefs(props) // ✅ Reactive
</script>

<template>
  <div>
    <h1>{{ title }}</h1>
    <p>Count: {{ count }}</p>
    <p>User: {{ user.name }}</p>
  </div>
</template>

Events - Child to Parent Communication

Components emit custom events to communicate with their parent. defineEmits() is used to declare which events a component can emit.

<!-- Child Component -->
<script setup>
const emit = defineEmits(['update', 'delete', 'statusChange'])

// With TypeScript
const emit = defineEmits<{
  update: [value: number]
  delete: [id: string]
  statusChange: [status: 'pending' | 'active' | 'completed']
}>()

const handleClick = () => {
  emit('update', 42)
}

const handleDelete = (id) => {
  emit('delete', id)
}

const handleStatusChange = () => {
  emit('statusChange', 'completed')
}
</script>

<!-- Parent Component -->
<script setup>
import ChildComponent from './ChildComponent.vue'

const handleUpdate = (value) => {
  console.log('Received:', value)
}

const handleDelete = (id) => {
  console.log('Delete:', id)
}
</script>

<template>
  <ChildComponent
    @update="handleUpdate"
    @delete="handleDelete"
    @status-change="(status) => console.log('Status:', status)"
  />
</template>

v-model - Two-Way Binding

v-model provides two-way data binding between parent and child components. It's syntactic sugar for passing a prop and listening to an event.

<!-- Custom Input Component -->
<script setup>
const props = defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])

const updateValue = (event) => {
  emit('update:modelValue', event.target.value)
}
</script>

<template>
  <input
    :value="modelValue"
    @input="updateValue"
    placeholder="Enter text"
  />
</template>

<!-- Parent Usage -->
<script setup>
import { ref } from 'vue'
import CustomInput from './CustomInput.vue'

const searchQuery = ref('')
</script>

<template>
  <!-- v-model is shorthand for :modelValue and @update:modelValue -->
  <CustomInput v-model="searchQuery" />
  <p>You typed: {{ searchQuery }}</p>
</template>

<!-- Multiple v-models -->
<script setup>
// Child component
const props = defineProps(['title', 'content'])
const emit = defineEmits(['update:title', 'update:content'])
</script>

<template>
  <div>
    <input
      :value="title"
      @input="$emit('update:title', $event.target.value)"
    />
    <textarea
      :value="content"
      @input="$emit('update:content', $event.target.value)"
    />
  </div>
</template>

<!-- Parent usage -->
<script setup>
const title = ref('')
const content = ref('')
</script>

<template>
  <MyComponent
    v-model:title="title"
    v-model:content="content"
  />
</template>

provide() / inject() - Dependency Injection

provide() and inject() enable passing data from ancestor to descendant components without prop drilling.

<!-- Root/Ancestor Component -->
<script setup>
import { ref, provide, readonly } from 'vue'

const theme = ref('light')
const user = ref({ name: 'John', role: 'admin' })

// Provide reactive values
provide('theme', theme)
provide('user', readonly(user)) // Readonly to prevent modification

// Provide methods
const toggleTheme = () => {
  theme.value = theme.value === 'light' ? 'dark' : 'light'
}
provide('toggleTheme', toggleTheme)

// Provide with symbol keys (recommended for libraries)
const ThemeSymbol = Symbol()
provide(ThemeSymbol, theme)
</script>

<!-- Descendant Component (any level deep) -->
<script setup>
import { inject } from 'vue'

// Inject with default value
const theme = inject('theme', 'light')
const user = inject('user')
const toggleTheme = inject('toggleTheme')

// Inject with symbol key
const ThemeSymbol = Symbol()
const themeFromSymbol = inject(ThemeSymbol)

// Type-safe injection with TypeScript
interface User {
  name: string
  role: string
}

const user = inject<User>('user')
const theme = inject<Ref<string>>('theme', ref('light'))
</script>

<template>
  <div :class="theme">
    <p>Current user: {{ user?.name }}</p>
    <button @click="toggleTheme">Toggle Theme</button>
  </div>
</template>

Slots - Content Distribution

Slots allow parent components to pass template content to child components, enabling flexible component composition.

<!-- Card Component with slots -->
<template>
  <div class="card">
    <header v-if="$slots.header" class="card-header">
      <slot name="header" />
    </header>
    
    <div class="card-body">
      <!-- Default slot -->
      <slot />
    </div>
    
    <footer v-if="$slots.footer" class="card-footer">
      <slot name="footer" />
    </footer>
  </div>
</template>

<!-- Usage -->
<template>
  <Card>
    <template #header>
      <h2>Card Title</h2>
      <button>×</button>
    </template>
    
    <!-- Default slot content -->
    <p>This is the main content of the card.</p>
    <p>It can contain multiple elements.</p>
    
    <template #footer>
      <button>Cancel</button>
      <button>Save</button>
    </template>
  </Card>
</template>

<!-- Scoped Slots - Pass data to parent -->
<script setup>
const items = ref([
  { id: 1, name: 'Item 1', price: 100 },
  { id: 2, name: 'Item 2', price: 200 },
])
</script>

<template>
  <div>
    <!-- Child exposes data through slot props -->
    <div v-for="item in items" :key="item.id">
      <slot :item="item" :index="index">
        <!-- Fallback content -->
        {{ item.name }}
      </slot>
    </div>
  </div>
</template>

<!-- Parent receives and uses slot props -->
<template>
  <ItemList>
    <template #default="{ item, index }">
      <div>
        <span>{{ index + 1 }}.</span>
        <strong>{{ item.name }}</strong>
        <span>${{ item.price }}</span>
      </div>
    </template>
  </ItemList>
</template>

Keep your own version of these notes — editable, searchable, and organised by your stack.

Start free