Props, Events, and Communication

Module 14: JavaScript Frontend Frameworks - Vue & Angular

Component Communication Patterns

One of the most important aspects of component-based development is how components communicate with each other. Vue provides several patterns for component communication, each suited for different scenarios.

flowchart TD A[Parent Component] --"Props"--> B[Child Component] B --"Events"--> A A --"Provide/Inject"--> C[Deeply Nested Component] D[Vuex/Pinia Store] -.-> A D -.-> B D -.-> C style A fill:#42b883,color:white style B fill:#35495e,color:white style C fill:#42b883,color:white style D fill:#35495e,color:white

In this lecture, we'll focus on the two primary methods of parent-child communication:

Real-world analogy: Think of component communication like a company hierarchy. Props are like tasks and information that flow from managers (parent components) to employees (child components). Events are like reports and notifications that employees send back up to managers. Sometimes, the CEO (global state) sends a company-wide memo that everyone can access directly.

Props: Parent-to-Child Communication

Props (short for "properties") are custom attributes that you can register on a component. When a value is passed to a prop attribute, it becomes a property on that component instance. Props establish a one-way downward binding between the parent and child components.

Declaring Props

There are several ways to declare props in a component:

// Simple array syntax
export default {
  props: ['title', 'likes', 'isPublished', 'commentIds']
}

// Object syntax with validation
export default {
  props: {
    // Basic type check (null/undefined values will pass any type validation)
    title: String,
    
    // Multiple possible types
    id: [String, Number],
    
    // Required prop
    author: {
      type: Object,
      required: true
    },
    
    // Default value
    status: {
      type: String,
      default: 'draft'
    },
    
    // Default value for object/array (must use factory function)
    commentIds: {
      type: Array,
      default: () => []
    },
    
    // Custom validator function
    priority: {
      type: Number,
      validator(value) {
        return value >= 1 && value <= 5
      }
    }
  }
}

In the Composition API with <script setup>, props are declared using the defineProps macro:

<script setup>
// Simple array syntax
const props = defineProps(['title', 'likes'])

// Object syntax with type checking
const props = defineProps({
  title: String,
  likes: Number
})

// With TypeScript type annotation (Vue 3.2+)
const props = defineProps<{
  title: string
  likes?: number
  author: { name: string, email: string }
}>()
</script>

Passing Props to Components

Once props are declared, a parent component can pass them like HTML attributes:

<!-- Static prop values -->
<blog-post title="My Journey with Vue"></blog-post>

<!-- Dynamic prop binding -->
<blog-post :title="post.title"></blog-post>

<!-- Shorthand for v-bind -->
<blog-post :title="post.title"></blog-post>

<!-- Passing an object with multiple properties -->
<blog-post v-bind="post"></blog-post>

The last example is equivalent to:

<blog-post 
  :id="post.id" 
  :title="post.title" 
  :content="post.content"
  :published-at="post.publishedAt"
></blog-post>

Prop Naming Conventions

Vue automatically converts prop names in camelCase to kebab-case when used in HTML templates, since HTML attributes are case-insensitive:

// In the component props definition
props: {
  postTitle: String  // camelCase in JavaScript
}

// In the parent's template
<blog-post post-title="Hello!"></blog-post>  // kebab-case in HTML

Best practice: Use camelCase for your prop names in JavaScript, and kebab-case when passing them in HTML templates. In single-file components with in-template components, you can use either convention consistently.

Prop Validation and Type Checking

Vue provides a robust system for validating props, which helps catch potential issues early and serves as documentation for how components should be used.

Available Prop Types

Vue can validate props against the following JavaScript native types:

Advanced Prop Validation Example

import ProfileImage from './ProfileImage.vue'

export default {
  props: {
    // Basic type checks
    username: String,
    age: Number,
    
    // Required value
    userId: {
      type: String,
      required: true
    },
    
    // Number with default
    maxItems: {
      type: Number,
      default: 10
    },
    
    // Object with defaults
    user: {
      type: Object,
      default() {
        return { 
          name: 'Guest', 
          role: 'visitor' 
        }
      }
    },
    
    // Custom validator for allowed values
    status: {
      type: String,
      validator(value) {
        return ['pending', 'active', 'inactive'].includes(value)
      }
    },
    
    // Custom component type
    profileImage: ProfileImage,
    
    // Allow multiple types
    identifier: [String, Number],
    
    // Complex nested object with validation
    settings: {
      type: Object,
      validator(obj) {
        return (
          obj.hasOwnProperty('theme') &&
          obj.hasOwnProperty('notifications') &&
          typeof obj.theme === 'string' &&
          typeof obj.notifications === 'boolean'
        )
      }
    }
  }
}

TypeScript Enhancement: When using TypeScript, you can leverage its type system for more detailed prop validation:

<script setup lang="ts">
interface User {
  id: string;
  name: string;
  email: string;
  role: 'admin' | 'user' | 'guest';
  settings: {
    theme: string;
    notifications: boolean;
  };
}

const props = defineProps<{
  user: User;
  isActive: boolean;
  lastLogin?: Date; // Optional prop
}>()
</script>

One-Way Data Flow

Props in Vue enforce a one-way downward binding between parent and child components. When the parent prop changes, it will flow down to the child, but not the other way around. This prevents child components from accidentally mutating the parent's state.

Important: You should not try to mutate props inside a child component. If you do, Vue will warn you in the console in development mode.

Common Patterns for Working with Props

There are two common cases where you might be tempted to mutate a prop:

1. Using a Prop as Initial Value

// INCORRECT: Mutating props directly
export default {
  props: ['initialCounter'],
  mounted() {
    this.initialCounter = this.initialCounter + 1 // BAD: mutating prop!
  }
}

// CORRECT: Use a local data property that uses the prop as initial value
export default {
  props: ['initialCounter'],
  data() {
    return {
      // Create a local copy instead
      counter: this.initialCounter
    }
  }
}

2. Transforming a Prop Value

// INCORRECT: Mutating props directly
export default {
  props: ['size'],
  mounted() {
    this.size = this.size.trim() // BAD: mutating prop!
  }
}

// CORRECT: Use a computed property
export default {
  props: ['size'],
  computed: {
    normalizedSize() {
      return this.size.trim()
    }
  }
}

Best practice: Always treat props as read-only in the child component. If you need to modify the data passed down from a parent, either:

  1. Make a local copy in data() or ref()
  2. Create a computed property based on the prop
  3. Emit an event to the parent suggesting a change

Events: Child-to-Parent Communication

While props flow down, events flow up. Vue's event system allows child components to communicate changes or actions to their parent components through custom events.

Emitting Events

Vue provides an event emitter interface on component instances through the $emit method:

// In a child component method
methods: {
  incrementCounter() {
    this.count++
    // Emit an event named 'increment' with the new count as payload
    this.$emit('increment', this.count)
  }
}

// Using the composition API with script setup
<script setup>
const emit = defineEmits(['increment', 'update'])

function incrementCounter() {
  count.value++
  emit('increment', count.value)
}
</script>

Listening for Events

The parent component can listen for events using v-on (or @ shorthand):

<template>
  <div>
    <p>Total count: {{ totalCount }}</p>
    
    <!-- Listen for the increment event -->
    <counter-component @increment="handleIncrement"></counter-component>
  </div>
</template>

<script>
import CounterComponent from './CounterComponent.vue'

export default {
  components: {
    CounterComponent
  },
  data() {
    return {
      totalCount: 0
    }
  },
  methods: {
    handleIncrement(count) {
      // Update parent state based on the event payload
      this.totalCount += count
    }
  }
}
</script>

Event Validation

Similar to props, you can declare the events a component emits, which serves as documentation and enables validation:

// Options API
export default {
  emits: ['increment', 'decrement']
}

// With validation
export default {
  emits: {
    // No validation
    increment: null,
    
    // With validation
    submit: (payload) => {
      // Return true if valid, false if invalid
      if (!payload.email || !payload.password) {
        console.warn('Invalid submit event payload!')
        return false
      }
      return true
    }
  }
}

// Composition API with <script setup>
<script setup>
// Simple array of event names
const emit = defineEmits(['increment', 'submit'])

// With TypeScript for better type checking
const emit = defineEmits<{
  (e: 'increment', value: number): void
  (e: 'submit', payload: { email: string, password: string }): void
}>()
</script>

v-model: Two-Way Binding with Components

v-model provides a convenient shorthand for creating two-way binding between a form input and state. This same pattern can be used with custom components, allowing them to work just like native form elements.

How v-model Works with Components

When used on a component, v-model is essentially a combination of a prop and an event:

<!-- This: -->
<custom-input v-model="searchText"></custom-input>

<!-- Is shorthand for: -->
<custom-input 
  :model-value="searchText" 
  @update:model-value="searchText = $event"
></custom-input>

For this to work, the component needs to:

  1. Accept a modelValue prop (or a custom name in Vue 3)
  2. Emit an update:modelValue event with the new value

Basic v-model Implementation

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

<script>
export default {
  props: ['modelValue'],
  emits: ['update:modelValue']
}
</script>

<!-- With Composition API -->
<script setup>
const props = defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])
</script>

Customizing v-model

Vue 3 allows customizing the name of the prop and event used for v-model:

<!-- CustomCheckbox.vue -->
<template>
  <input 
    type="checkbox"
    :checked="checked"
    @change="$emit('update:checked', $event.target.checked)"
  >
</template>

<script>
export default {
  props: {
    checked: Boolean
  },
  emits: ['update:checked']
}
</script>

<!-- Parent component -->
<template>
  <custom-checkbox v-model:checked="isChecked"></custom-checkbox>
</template>

Multiple v-model Bindings

Vue 3 allows using multiple v-model bindings on a single component:

<!-- UserForm.vue -->
<template>
  <div>
    <input 
      :value="firstName"
      @input="$emit('update:firstName', $event.target.value)"
    >
    
    <input 
      :value="lastName"
      @input="$emit('update:lastName', $event.target.value)"
    >
  </div>
</template>

<script>
export default {
  props: {
    firstName: String,
    lastName: String
  },
  emits: ['update:firstName', 'update:lastName']
}
</script>

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

v-model Modifiers

Vue 3 allows custom components to handle v-model modifiers. For example, let's implement a custom .capitalize modifier:

<!-- TextInput.vue -->
<template>
  <input
    :value="modelValue"
    @input="handleInput"
  >
</template>

<script>
export default {
  props: {
    modelValue: String,
    modelModifiers: {
      default: () => ({})
    }
  },
  emits: ['update:modelValue'],
  methods: {
    handleInput(e) {
      let value = e.target.value
      
      // Apply the capitalize modifier if it exists
      if (this.modelModifiers.capitalize) {
        value = value.charAt(0).toUpperCase() + value.slice(1)
      }
      
      this.$emit('update:modelValue', value)
    }
  }
}
</script>

<!-- Parent usage -->
<text-input v-model.capitalize="text"></text-input>

Slots: Content Distribution

Slots provide a way to pass content from a parent component to a child component. They complement props and events by allowing the parent to pass template content (HTML markup) to the child component.

Basic Slot Usage

<!-- CardComponent.vue -->
<template>
  <div class="card">
    <div class="card-header">
      <h3>{{ title }}</h3>
    </div>
    
    <div class="card-body">
      <!-- This is where content from the parent will be injected -->
      <slot>
        Fallback content (displayed if no content is provided)
      </slot>
    </div>
  </div>
</template>

<script>
export default {
  props: {
    title: {
      type: String,
      default: 'Card Title'
    }
  }
}
</script>

<!-- Parent component -->
<template>
  <card-component title="User Profile">
    <!-- This content will be inserted into the slot -->
    <p>Name: {{ user.name }}</p>
    <p>Email: {{ user.email }}</p>
  </card-component>
</template>

Named Slots

For components with multiple slots, you can use named slots to specify where each content should go:

<!-- PageLayout.vue -->
<template>
  <div class="page-layout">
    <header>
      <slot name="header">Default header</slot>
    </header>
    
    <main>
      <!-- Default/unnamed slot -->
      <slot>Default main content</slot>
    </main>
    
    <footer>
      <slot name="footer">Default footer</slot>
    </footer>
  </div>
</template>

<!-- Parent component -->
<template>
  <page-layout>
    <template #header>
      <!-- v-slot:header or #header shorthand -->
      <h1>Website Title</h1>
      <nav>...</nav>
    </template>
    
    <!-- Default slot content -->
    <p>Main content goes here</p>
    
    <template #footer>
      <p>© 2025 My Company</p>
    </template>
  </page-layout>
</template>

Scoped Slots

Scoped slots allow the child component to pass data back to the parent's slot content, enabling more flexible component compositions:

<!-- ItemList.vue -->
<template>
  <ul>
    <li v-for="(item, index) in items" :key="item.id">
      <!-- Pass item data to the parent slot template -->
      <slot :item="item" :index="index" :remove="removeItem">
        <!-- Fallback if no slot content provided -->
        {{ item.name }}
      </slot>
    </li>
  </ul>
</template>

<script>
export default {
  props: {
    items: {
      type: Array,
      required: true
    }
  },
  methods: {
    removeItem(index) {
      this.$emit('remove', index)
    }
  }
}
</script>

<!-- Parent component -->
<template>
  <item-list :items="products" @remove="removeProduct">
    <!-- The slotProps object contains all properties passed to the slot -->
    <template #default="slotProps">
      <div class="product-item">
        <img :src="slotProps.item.image" :alt="slotProps.item.name">
        <h3>{{ slotProps.item.name }}</h3>
        <p>{{ slotProps.item.price }}</p>
        <button @click="slotProps.remove(slotProps.index)">Remove</button>
      </div>
    </template>
  </item-list>
  
  <!-- With destructuring for cleaner template -->
  <item-list :items="users">
    <template #default="{ item, index, remove }">
      <div class="user-item">
        <h3>{{ item.name }}</h3>
        <button @click="remove(index)">Remove</button>
      </div>
    </template>
  </item-list>
</template>

Real-world use case: Scoped slots are ideal for reusable container components like data tables, lists, or layout components where you want to delegate rendering but maintain control over the data.

Advanced Communication Patterns

For communication beyond parent-child relationships, Vue offers additional patterns:

Provide/Inject: Deep Component Communication

The Provide/Inject pattern allows an ancestor component to "provide" data that can be "injected" into any descendant component, regardless of how deep it is in the component hierarchy.

flowchart TD A[Root Component] -- provides theme --> B[Component Level 1] B --> C[Component Level 2] C --> D[Component Level 3] D -- injects theme --> E[Deeply Nested Component] style A fill:#42b883,color:white style B fill:#35495e,color:white style C fill:#35495e,color:white style D fill:#35495e,color:white style E fill:#42b883,color:white
// Root component providing values
export default {
  provide() {
    return {
      theme: 'dark',
      user: this.user
    }
  },
  data() {
    return {
      user: { id: 1, name: 'Alice' }
    }
  }
}

// Composition API version
<script setup>
import { provide, ref } from 'vue'

const user = ref({ id: 1, name: 'Alice' })
provide('theme', 'dark')
provide('user', user)
</script>

// Deeply nested component injecting values
export default {
  inject: ['theme', 'user']
}

// With defaults
export default {
  inject: {
    theme: {
      from: 'theme', // optional if same name
      default: 'light'
    },
    user: {
      default: () => ({ id: 0, name: 'Guest' })
    }
  }
}

// Composition API version
<script setup>
import { inject } from 'vue'

const theme = inject('theme', 'light')
const user = inject('user', { id: 0, name: 'Guest' })
</script>

Best practice: Use provide/inject for data that truly needs to be accessible by many components in the tree (like themes, user authentication state, or application-wide settings). For most cases, prefer more explicit communication or state management.

State Management with Pinia/Vuex

For more complex applications, centralized state management libraries like Pinia (newer, recommended) or Vuex (traditional) provide a robust solution for sharing state between components regardless of their relationship.

flowchart TD A[Pinia/Vuex Store] --> B[Component A] A --> C[Component B] A --> D[Component C] B --> A C --> A D --> A style A fill:#42b883,color:white style B fill:#35495e,color:white style C fill:#35495e,color:white style D fill:#35495e,color:white

Real-world use case: State management is ideal for application-wide data like user authentication, shopping carts, UI preferences, or any data that needs to be accessed by multiple unrelated components.

We'll cover state management in detail in a future lecture.

Practical Example: Shopping Cart Component

Let's bring these concepts together with a practical example of a shopping cart system with multiple components:

flowchart TD A[App] --> B[ProductList] A --> C[ShoppingCart] B --> D[ProductItem] C --> E[CartItem] style A fill:#42b883,color:white style B fill:#35495e,color:white style C fill:#35495e,color:white style D fill:#42b883,color:white style E fill:#42b883,color:white
// App.vue
<template>
  <div class="app">
    <h1>Online Store</h1>
    
    <div class="main-content">
      <product-list 
        :products="products" 
        @add-to-cart="addToCart"
      ></product-list>
      
      <shopping-cart 
        :cart-items="cartItems" 
        @update-quantity="updateCartQuantity"
        @remove-item="removeFromCart"
      ></shopping-cart>
    </div>
  </div>
</template>

<script>
import ProductList from './components/ProductList.vue'
import ShoppingCart from './components/ShoppingCart.vue'

export default {
  components: {
    ProductList,
    ShoppingCart
  },
  data() {
    return {
      products: [
        { id: 1, name: 'Laptop', price: 999.99, image: 'laptop.jpg' },
        { id: 2, name: 'Smartphone', price: 699.99, image: 'smartphone.jpg' },
        { id: 3, name: 'Headphones', price: 149.99, image: 'headphones.jpg' }
      ],
      cartItems: []
    }
  },
  methods: {
    addToCart(product) {
      // Check if product is already in cart
      const existingItem = this.cartItems.find(item => item.id === product.id)
      
      if (existingItem) {
        // Update quantity if already in cart
        this.updateCartQuantity(product.id, existingItem.quantity + 1)
      } else {
        // Add new item with quantity 1
        this.cartItems.push({
          id: product.id,
          name: product.name,
          price: product.price,
          image: product.image,
          quantity: 1
        })
      }
    },
    
    updateCartQuantity(productId, newQuantity) {
      const item = this.cartItems.find(item => item.id === productId)
      if (item) {
        if (newQuantity > 0) {
          item.quantity = newQuantity
        } else {
          this.removeFromCart(productId)
        }
      }
    },
    
    removeFromCart(productId) {
      this.cartItems = this.cartItems.filter(item => item.id !== productId)
    }
  }
}
</script>

// ProductList.vue
<template>
  <div class="product-list">
    <h2>Products</h2>
    
    <div class="products-grid">
      <product-item
        v-for="product in products"
        :key="product.id"
        :product="product"
        @add-to-cart="$emit('add-to-cart', product)"
      ></product-item>
    </div>
  </div>
</template>

<script>
import ProductItem from './ProductItem.vue'

export default {
  components: {
    ProductItem
  },
  props: {
    products: {
      type: Array,
      required: true
    }
  },
  emits: ['add-to-cart']
}
</script>

// ProductItem.vue
<template>
  <div class="product-item">
    <img :src="'/images/' + product.image" :alt="product.name">
    <h3>{{ product.name }}</h3>
    <p class="price">{{ formatPrice(product.price) }}</p>
    <button @click="$emit('add-to-cart')">Add to Cart</button>
  </div>
</template>

<script>
export default {
  props: {
    product: {
      type: Object,
      required: true
    }
  },
  emits: ['add-to-cart'],
  methods: {
    formatPrice(price) {
      return '$' + price.toFixed(2)
    }
  }
}
</script>

// ShoppingCart.vue
<template>
  <div class="shopping-cart">
    <h2>Shopping Cart</h2>
    
    <p v-if="cartItems.length === 0">Your cart is empty</p>
    
    <div v-else>
      <cart-item
        v-for="item in cartItems"
        :key="item.id"
        :item="item"
        @update-quantity="updateQuantity"
        @remove="$emit('remove-item', item.id)"
      ></cart-item>
      
      <div class="cart-total">
        <h3>Total: {{ formatPrice(totalPrice) }}</h3>
        <button class="checkout-button">Checkout</button>
      </div>
    </div>
  </div>
</template>

<script>
import CartItem from './CartItem.vue'

export default {
  components: {
    CartItem
  },
  props: {
    cartItems: {
      type: Array,
      required: true
    }
  },
  emits: ['update-quantity', 'remove-item'],
  computed: {
    totalPrice() {
      return this.cartItems.reduce((total, item) => {
        return total + (item.price * item.quantity)
      }, 0)
    }
  },
  methods: {
    formatPrice(price) {
      return '$' + price.toFixed(2)
    },
    updateQuantity(itemId, quantity) {
      this.$emit('update-quantity', itemId, quantity)
    }
  }
}
</script>

// CartItem.vue
<template>
  <div class="cart-item">
    <img :src="'/images/' + item.image" :alt="item.name" class="cart-item-image">
    
    <div class="cart-item-details">
      <h4>{{ item.name }}</h4>
      <p>{{ formatPrice(item.price) }} each</p>
    </div>
    
    <div class="cart-item-actions">
      <div class="quantity-control">
        <button @click="updateItemQuantity(item.quantity - 1)">-</button>
        <span class="quantity">{{ item.quantity }}</span>
        <button @click="updateItemQuantity(item.quantity + 1)">+</button>
      </div>
      
      <p class="item-total">{{ formatPrice(item.price * item.quantity) }}</p>
      
      <button class="remove-button" @click="$emit('remove')">×</button>
    </div>
  </div>
</template>

<script>
export default {
  props: {
    item: {
      type: Object,
      required: true
    }
  },
  emits: ['update-quantity', 'remove'],
  methods: {
    formatPrice(price) {
      return '$' + price.toFixed(2)
    },
    updateItemQuantity(newQuantity) {
      if (newQuantity >= 1) {
        this.$emit('update-quantity', this.item.id, newQuantity)
      } else if (newQuantity === 0) {
        this.$emit('remove')
      }
    }
  }
}
</script>

This example demonstrates:

Activities for Practice

Exercise 1: Parent-Child Component Communication

Create a parent component that contains a list of items (e.g., tasks, products, etc.) and a form to add new items. Then create a child component that displays each individual item with options to edit or delete it.

Implement:

  • Props to pass the item data from parent to child
  • Events to communicate changes (edits, deletions) from child to parent
  • Prop validation to ensure the data is in the correct format
  • At least one computed property based on the props

Exercise 2: Custom Form Components with v-model

Create a set of custom form components that work with v-model:

  • A text input component with built-in validation (e.g., required, min/max length, pattern)
  • A custom select/dropdown component that emits the selected value
  • A rating component (e.g., 1-5 stars) that allows users to select a rating

Then create a form that uses these components with v-model to collect user input. Display the form values in real-time as they change.

Exercise 3: Content Distribution with Slots

Create a flexible card component that uses slots to allow various content layouts:

  • A default slot for the main content
  • Named slots for header, footer, and sidebar
  • A scoped slot that passes some data from the card to the parent (e.g., isExpanded state)

Then create a page that uses multiple instances of your card component with different content combinations.

Additional Resources