Vue Components and Props

Module 25: Frontend Frameworks & State Management

Components: The Building Blocks of Vue Applications

Components are the fundamental building blocks of Vue applications. They encapsulate reusable code, maintain their own state, and interact with one another to build complex UIs.

graph TD A[App.vue] --> B[Header.vue] A --> C[Main Content] A --> D[Footer.vue] C --> E[ProductList.vue] E --> F[ProductItem.vue] F --> G[AddToCart.vue] F --> H[ProductRating.vue] style A fill:#42b883 style B fill:#64b687 style C fill:#64b687 style D fill:#64b687 style E fill:#85c79c style F fill:#a6d8b1 style G fill:#c8e9d3 style H fill:#c8e9d3 classDef default stroke:#333,stroke-width:2px;

Think of components like LEGO blocks – each has a specific purpose and can be combined in various ways to create something larger. Just as you might have specialized LEGO pieces for different functions (wheels, windows, etc.), in Vue, you create specialized components for different parts of your UI (buttons, forms, navigation bars, etc.).

Benefits of Component-Based Architecture

Component Registration

Before using a component, you need to register it. Vue provides two methods for component registration: global and local.

Global Registration

Globally registered components can be used anywhere in your application without importing them individually:

// main.js
import { createApp } from 'vue'
import App from './App.vue'
import BaseButton from './components/BaseButton.vue'
import BaseInput from './components/BaseInput.vue'

const app = createApp(App)

// Register components globally
app.component('BaseButton', BaseButton)
app.component('BaseInput', BaseInput)

app.mount('#app')

While convenient, global registration has drawbacks:

Global registration is best reserved for truly shared base components used throughout your application.

Local Registration

Locally registered components must be imported and registered in each component that uses them:

<template>
  <div>
    <h1>Product Page</h1>
    <ProductDetail :product="product" />
    <RelatedProducts :category="product.category" />
  </div>
</template>

<script>
import ProductDetail from './components/ProductDetail.vue'
import RelatedProducts from './components/RelatedProducts.vue'

export default {
  name: 'ProductPage',
  components: {
    ProductDetail,
    RelatedProducts
  },
  data() {
    return {
      product: { /* ... */ }
    }
  }
}
</script>

With the Composition API and <script setup>, the process is even simpler:

<template>
  <div>
    <h1>Product Page</h1>
    <ProductDetail :product="product" />
    <RelatedProducts :category="product.category" />
  </div>
</template>

<script setup>
import { ref } from 'vue'
import ProductDetail from './components/ProductDetail.vue'
import RelatedProducts from './components/RelatedProducts.vue'

// Components imported in <script setup> are automatically registered
const product = ref({ /* ... */ })
</script>

Local registration is the recommended approach for most components because it:

Component Communication with Props

Props are custom attributes you can register on a component, allowing you to pass data from parent components to child components.

graph LR A[Parent Component] -->|Props Down| B[Child Component] B -->|Events Up| A style A fill:#42b883 style B fill:#64b687 classDef default stroke:#333,stroke-width:2px;

Real-world analogy: Props are like arguments passed to a function. Just as you provide specific inputs to a function to get specific outputs, you pass props to a component to customize its behavior and appearance.

Defining Props (Options API)

<template>
  <div class="product-card">
    <img :src="image" :alt="name" />
    <h3>{{ name }}</h3>
    <p class="price">{{ formatPrice(price) }}</p>
    <p v-if="inStock">In Stock</p>
    <p v-else class="out-of-stock">Out of Stock</p>
    <button :disabled="!inStock">Add to Cart</button>
  </div>
</template>

<script>
export default {
  name: 'ProductCard',
  
  // Define props the component accepts
  props: {
    name: {
      type: String,
      required: true
    },
    price: {
      type: Number,
      required: true
    },
    image: {
      type: String,
      default: '/images/default-product.jpg'
    },
    inStock: {
      type: Boolean,
      default: true
    }
  },
  
  methods: {
    formatPrice(value) {
      return `${value.toFixed(2)}`;
    }
  }
}
</script>

Defining Props (Composition API)

<template>
  <div class="product-card">
    <img :src="image" :alt="name" />
    <h3>{{ name }}</h3>
    <p class="price">{{ formatPrice(price) }}</p>
    <p v-if="inStock">In Stock</p>
    <p v-else class="out-of-stock">Out of Stock</p>
    <button :disabled="!inStock">Add to Cart</button>
  </div>
</template>

<script setup>
import { defineProps } from 'vue';

// Define props with the defineProps compiler macro
const props = defineProps({
  name: {
    type: String,
    required: true
  },
  price: {
    type: Number,
    required: true
  },
  image: {
    type: String,
    default: '/images/default-product.jpg'
  },
  inStock: {
    type: Boolean,
    default: true
  }
});

function formatPrice(value) {
  return `${value.toFixed(2)}`;
}
</script>

Using Components with Props

<template>
  <div class="products-list">
    <h2>Featured Products</h2>
    <div class="products-grid">
      <ProductCard
        v-for="product in products"
        :key="product.id"
        :name="product.name"
        :price="product.price"
        :image="product.image"
        :in-stock="product.inStock"
      />
    </div>
  </div>
</template>

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

const products = ref([
  {
    id: 1,
    name: 'Wireless Headphones',
    price: 99.99,
    image: '/images/headphones.jpg',
    inStock: true
  },
  {
    id: 2,
    name: 'Smartphone',
    price: 699.99,
    image: '/images/smartphone.jpg',
    inStock: false
  },
  // More products...
]);
</script>

Note how prop names use camelCase in JavaScript (e.g., inStock) but kebab-case in HTML attributes (e.g., in-stock). This is a Vue convention for better compatibility with HTML standards.

Prop Validation

Vue provides a robust prop validation system that helps catch errors and document component usage:

Type Validation

You can specify the expected data type(s) for a prop:

props: {
  // Basic type check (`null` and `undefined` match any type)
  propA: Number,
  
  // Multiple possible types
  propB: [String, Number],
  
  // Required string
  propC: {
    type: String,
    required: true
  },
  
  // Number with default value
  propD: {
    type: Number,
    default: 100
  }
}

Vue supports validation for all JavaScript primitive types:

Default Values

For object or array defaults, you must use a factory function to return a fresh copy each time:

props: {
  // Simple default value
  size: {
    type: String,
    default: 'medium'
  },
  
  // Object default (using factory function)
  user: {
    type: Object,
    default() {
      return { name: 'Guest' }
    }
  },
  
  // Array default (using factory function)
  items: {
    type: Array,
    default() {
      return []
    }
  }
}

Custom Validators

You can define custom validation functions for more complex logic:

props: {
  // Custom validator
  status: {
    type: String,
    validator(value) {
      // The value must match one of these strings
      return ['draft', 'published', 'archived'].includes(value)
    }
  },
  
  // Range validation
  rating: {
    type: Number,
    validator(value) {
      return value >= 1 && value <= 5
    }
  }
}

Real-world analogy: Prop validation is like a security check at an entrance. Just as security ensures only authorized people with proper credentials enter a building, prop validation ensures that components receive the correct data types and values they need to function properly.

One-Way Data Flow

Props in Vue follow a one-way data flow pattern: parent to child. This means:

graph TD A[ParentComponent] -->|data = 123| B[ChildComponent] B -.-x|Cannot modify| A style A fill:#42b883 style B fill:#64b687 classDef default stroke:#333,stroke-width:2px;

This one-way flow helps maintain a predictable data flow and prevents unexpected side effects.

What If a Child Needs to Modify Props?

There are two primary patterns for cases where a child component needs to influence the parent:

1. Using props as initial values for local data:

<script>
export default {
  props: {
    initialCounter: {
      type: Number,
      default: 0
    }
  },
  data() {
    return {
      // Using the prop as initial value for local state
      counter: this.initialCounter
    }
  }
}
</script>

2. Using computed properties with getters and setters:

<script>
export default {
  props: {
    modelValue: {
      type: String,
      required: true
    }
  },
  computed: {
    // Computed property with getter and setter
    inputValue: {
      get() {
        return this.modelValue
      },
      set(value) {
        // Emit event instead of mutating prop directly
        this.$emit('update:modelValue', value)
      }
    }
  }
}
</script>

Real-world analogy: This is like a restaurant. The chef (parent component) prepares the dishes and sends them out with waiters. Customers (child components) can enjoy the food as served, but they cannot go into the kitchen to change the recipe. If they want modifications, they must send requests back through the waiter (events).

Props vs. Data

Understanding the distinction between props and component data is essential:

Props Data
Passed from parent component Defined within the component
Should not be mutated Can be freely modified
Update when parent changes them Updates trigger component re-renders
Define the component's API Define the component's internal state

Example: Props and Data Working Together

<template>
  <div class="expandable-text">
    <p>
      {{ isExpanded ? fullText : truncatedText }}
    </p>
    <button v-if="needsExpansion" @click="toggleExpand">
      {{ isExpanded ? 'Read Less' : 'Read More' }}
    </button>
  </div>
</template>

<script>
export default {
  name: 'ExpandableText',
  props: {
    text: {
      type: String,
      required: true
    },
    maxLength: {
      type: Number,
      default: 100
    }
  },
  data() {
    return {
      isExpanded: false
    }
  },
  computed: {
    needsExpansion() {
      return this.text.length > this.maxLength;
    },
    truncatedText() {
      if (this.text.length <= this.maxLength) {
        return this.text;
      }
      return this.text.slice(0, this.maxLength) + '...';
    },
    fullText() {
      return this.text;
    }
  },
  methods: {
    toggleExpand() {
      this.isExpanded = !this.isExpanded;
    }
  }
}
</script>

In this example:

Dynamic Props

Props aren't limited to static values; they can be dynamically bound to data in the parent component:

Binding to Variables

<template>
  <div>
    <h1>Product Configurator</h1>
    
    <div class="controls">
      <label>
        Product Title:
        <input v-model="productTitle" />
      </label>
      
      <label>
        Price:
        <input type="number" v-model.number="productPrice" />
      </label>
      
      <label>
        <input type="checkbox" v-model="productAvailable" />
        Available
      </label>
    </div>
    
    <!-- Dynamic props -->
    <ProductDisplay
      :title="productTitle"
      :price="productPrice"
      :available="productAvailable"
    />
  </div>
</template>

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

const productTitle = ref('Awesome Product');
const productPrice = ref(49.99);
const productAvailable = ref(true);
</script>

Binding Multiple Props Using v-bind Object

You can pass all properties of an object as props using v-bind without an argument:

<template>
  <div>
    <UserProfile v-bind="user" />
    
    <!-- Equivalent to: -->
    <UserProfile
      :name="user.name"
      :email="user.email"
      :role="user.role"
      :verified="user.verified"
    />
  </div>
</template>

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

const user = ref({
  name: 'Jane Doe',
  email: 'jane@example.com',
  role: 'Admin',
  verified: true
});
</script>

This is especially useful when passing many props or when props might change dynamically based on API responses.

Component Organization Patterns

As applications grow, organizing components effectively becomes crucial. Here are some common patterns:

Base Components

Base components are highly reusable components that are used throughout your application. They typically implement basic UI elements with enhanced functionality:

components/
├── base/
│   ├── BaseButton.vue
│   ├── BaseInput.vue
│   ├── BaseCheckbox.vue
│   ├── BaseSelect.vue
│   └── BaseCard.vue

Example of a BaseButton component:

<template>
  <button
    :class="[
      'base-button',
      `base-button--${size}`,
      `base-button--${variant}`,
      { 'base-button--loading': loading }
    ]"
    :disabled="disabled || loading"
    :type="type"
    @click="onClick"
  >
    <span v-if="loading" class="spinner"></span>
    <slot>Button</slot>
  </button>
</template>

<script>
export default {
  name: 'BaseButton',
  props: {
    variant: {
      type: String,
      default: 'primary',
      validator: value => ['primary', 'secondary', 'danger', 'text'].includes(value)
    },
    size: {
      type: String,
      default: 'medium',
      validator: value => ['small', 'medium', 'large'].includes(value)
    },
    type: {
      type: String,
      default: 'button',
      validator: value => ['button', 'submit', 'reset'].includes(value)
    },
    loading: {
      type: Boolean,
      default: false
    },
    disabled: {
      type: Boolean,
      default: false
    }
  },
  methods: {
    onClick(event) {
      this.$emit('click', event);
    }
  }
}
</script>

Feature-Based Organization

Group components by feature or domain rather than by type:

src/
├── components/
│   ├── base/        # Base UI components
│   ├── product/     # Product-related components
│   │   ├── ProductCard.vue
│   │   ├── ProductDetails.vue
│   │   ├── ProductReviews.vue
│   │   └── ProductFilter.vue
│   ├── checkout/    # Checkout-related components
│   │   ├── CartSummary.vue
│   │   ├── PaymentForm.vue
│   │   └── ShippingForm.vue
│   └── user/        # User-related components
│       ├── ProfileCard.vue
│       ├── LoginForm.vue
│       └── RegisterForm.vue

This organization makes it easier to locate components when working on a specific feature and keeps related components together.

Composing Components with Slots

Slots allow you to create flexible component templates that can be filled with different content:

<!-- CardComponent.vue -->
<template>
  <div class="card">
    <div class="card-header">
      <slot name="header">
        <!-- Default content if no header slot is provided -->
        Default Header
      </slot>
    </div>
    
    <div class="card-body">
      <slot>
        <!-- Default content if no default slot is provided -->
        Default content
      </slot>
    </div>
    
    <div class="card-footer">
      <slot name="footer">
        <!-- Default content if no footer slot is provided -->
        Default Footer
      </slot>
    </div>
  </div>
</template>

<!-- Usage -->
<CardComponent>
  <template #header>
    <h2>Custom Header</h2>
  </template>
  
  <p>This is the main content of the card.</p>
  
  <template #footer>
    <button>Action Button</button>
  </template>
</CardComponent>

We'll dive deeper into slots in future lectures, but they're an essential part of creating flexible, composable components.

Practice Activity

Building a Component Library

Create a small component library with the following components:

  1. Create a BaseButton component with props for:
    • variant (primary, secondary, danger)
    • size (small, medium, large)
    • disabled state
    • loading state
  2. Create a BaseCard component with:
    • slots for header, body (default), and footer
    • props for optional border, shadow, and padding settings
  3. Create a UserProfile component that:
    • accepts user data as props (name, email, avatar, role)
    • uses your BaseCard component to display the user info
    • validates the user props appropriately
  4. Create an App component that:
    • displays multiple UserProfile components with different data
    • includes a form to edit a selected user's data

Start with this template for the BaseButton component:

<template>
  <button
    :class="[
      'base-button',
      `base-button--${variant}`,
      `base-button--${size}`,
      { 'base-button--disabled': disabled },
      { 'base-button--loading': loading }
    ]"
    :disabled="disabled || loading"
    @click="$emit('click')"
  >
    <span v-if="loading" class="spinner"></span>
    <slot>Button</slot>
  </button>
</template>

<script>
// TODO: Implement component with appropriate props
</script>

<style scoped>
.base-button {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-weight: 500;
  transition: background-color 0.2s, opacity 0.2s;
}

.base-button--primary {
  background-color: #42b883;
  color: white;
}

.base-button--secondary {
  background-color: #34495e;
  color: white;
}

.base-button--danger {
  background-color: #e74c3c;
  color: white;
}

.base-button--small {
  padding: 6px 12px;
  font-size: 0.875rem;
}

.base-button--medium {
  padding: 8px 16px;
  font-size: 1rem;
}

.base-button--large {
  padding: 10px 20px;
  font-size: 1.125rem;
}

.base-button--disabled,
.base-button--loading {
  opacity: 0.7;
  cursor: not-allowed;
}

.spinner {
  display: inline-block;
  width: 1em;
  height: 1em;
  margin-right: 0.5rem;
  border: 2px solid currentColor;
  border-top-color: transparent;
  border-radius: 50%;
  animation: spin 0.8s linear infinite;
}

@keyframes spin {
  from { transform: rotate(0deg); }
  to { transform: rotate(360deg); }
}
</style>

Key Takeaways

Additional Resources