Skip to main content

🧩 Vue Components and Props

Real apps aren't one giant file β€” they're trees of small, reusable components that pass data to one another. This lesson shows you how to define components, register them, and feed them data through props, the one-way channel that keeps a Vue app predictable.

🎯 Learning Objectives

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

  • Explain the benefits of a component-based architecture and how a component tree is structured
  • Register components both globally and locally, and know when to use each
  • Pass data to a child with props, and validate them with types, defaults, and custom validators
  • Describe Vue's one-way data flow and the correct patterns when a child needs to change a value
  • Distinguish props from local data and organize components for a growing codebase

Estimated Time: 35–45 minutes  β€’  Difficulty: Intermediate

Hands-on: Build a reusable ProductCard component with validated props and render a grid of products.

In This Lesson

Components: The Building Blocks

A component is a self-contained, reusable piece of UI that bundles its own markup, styling, and behavior. Instead of one enormous template, you build an application as a tree of components β€” a root App that contains a header, a product list, each list item, and so on.

πŸ’‘ An analogy: Components are like LEGO bricks. Each brick has a defined shape and purpose, and you combine specialized bricks β€” buttons, cards, forms β€” to build something far larger than any single piece.
graph TD A[App.vue] --> B[SiteHeader] A --> C[ProductList] A --> D[SiteFooter] C --> E[ProductCard] E --> F[AddToCart] E --> G[ProductRating]

βœ… Why components matter

  • Reusability β€” write a ProductCard once, render it a hundred times.
  • Maintainability β€” a bug lives in one small file, not scattered across a monolith.
  • Collaboration β€” teammates can own different components in parallel.
  • Testability β€” a component can be tested in isolation from the rest of the app.

Registering Components

Before you can use a component in a template, Vue needs to know it exists. There are two ways to make that introduction.

Global registration

A globally registered component is available everywhere without importing it again:

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

const app = createApp(App);
app.component('BaseButton', BaseButton);  // usable in any template now
app.mount('#app');

Convenient, but it has costs: globally registered components are harder to tree-shake, and a template that uses <BaseButton> gives no hint about where the component comes from. Reserve global registration for a handful of truly universal base components.

Local registration (preferred)

With <script setup>, simply importing a component registers it β€” no extra step:

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

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

// Imported components are automatically available in the template
const product = ref({ name: 'Widget', category: 'gadgets' });
</script>

πŸ“– Rule of thumb

Register locally by default. Dependencies stay explicit, bundles stay lean, and anyone reading the file can see exactly which components it relies on.

Passing Data with Props

Props (short for "properties") are custom attributes a parent sets on a child to hand it data. They are the primary way information flows down the component tree.

Props down, events up A parent component passes data to a child through props flowing downward, and the child communicates back to the parent by emitting events flowing upward. Parent Component Child Component props ↓ events ↑
Figure 1 β€” The core contract: data flows down via props, and children ask for changes by emitting events back up (covered in the next lesson).
πŸ’‘ An analogy: Props are like function arguments. You call a function with specific inputs to get specific behavior; you render a component with specific props to get specific output.

Declaring props

In <script setup>, declare props with the defineProps compiler macro (no import needed):

<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>
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 new Intl.NumberFormat('en-US', {
    style: 'currency', currency: 'USD'
  }).format(value);
}
</script>

Using the component

<template>
  <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>
</template>

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

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

⚠️ camelCase vs kebab-case

Declare props in camelCase in JavaScript (inStock), but bind them in kebab-case in the template (:in-stock). Both refer to the same prop β€” this matches HTML's case-insensitive attribute convention.

Prop Validation & Defaults

Vue can validate incoming props and warn you in development when a parent passes the wrong thing. This catches bugs early and doubles as living documentation of a component's API.

Types and defaults

defineProps({
  propA: Number,                    // shorthand: just the type
  propB: [String, Number],          // one of several types
  propC: { type: String, required: true },
  propD: { type: Number, default: 100 }
});

Supported types include String, Number, Boolean, Array, Object, Function, Symbol, and custom constructors.

⚠️ Object and array defaults need a factory

A default that is an object or array must be returned from a function, so every instance gets its own fresh copy rather than sharing one:

defineProps({
  user:  { type: Object, default: () => ({ name: 'Guest' }) },
  items: { type: Array,  default: () => [] }
});

Custom validators

For rules beyond type checking, supply a validator function that returns true/false:

defineProps({
  status: {
    type: String,
    validator: (value) => ['draft', 'published', 'archived'].includes(value)
  },
  rating: {
    type: Number,
    validator: (value) => value >= 1 && value <= 5
  }
});
πŸ’‘ An analogy: Prop validation is the security check at a building entrance. Just as security ensures only people with valid credentials get in, validation ensures a component only receives data it knows how to handle.

One-Way Data Flow

Props flow in one direction: parent to child. When the parent's data updates, the new value flows down. But a child must not reassign a prop it receives β€” doing so triggers a console warning, because it breaks the predictable, top-down flow of data.

⚠️ Don't mutate props

// ❌ Wrong β€” reassigning a prop inside the child
props.title = 'New title';   // Vue warns: avoid mutating a prop directly

When a child genuinely needs to influence a value, use one of these patterns instead.

Pattern 1 β€” seed local state from a prop

If the prop is only an initial value the child then owns:

import { ref } from 'vue';
const props = defineProps({ initialCounter: { type: Number, default: 0 } });
const counter = ref(props.initialCounter);   // now the child owns 'counter'

Pattern 2 β€” a writable computed that emits

For a value the parent must stay in sync with (the basis of v-model on components):

import { computed } from 'vue';
const props = defineProps({ modelValue: String });
const emit = defineEmits(['update:modelValue']);

const inputValue = computed({
  get: () => props.modelValue,
  set: (value) => emit('update:modelValue', value)  // ask the parent to change it
});
πŸ’‘ An analogy: Think of a restaurant. The chef (parent) sends out dishes via waiters (props). Diners (children) enjoy the food but can't march into the kitchen to change the recipe β€” they send requests back through the waiter (events). One-way flow keeps the kitchen sane.

Props vs Data

New Vue developers often blur the line between props and a component's own local state. The distinction is fundamental:

PropsLocal state (ref / reactive)
Passed in from the parentCreated inside the component
Read-only from the child's viewFreely readable and writable
Change when the parent changes themChanges are owned by this component
Define the component's public APIDefine the component's internal workings

They work together

Here a component takes text and maxLength as props, but owns its isExpanded state:

<template>
  <div class="expandable-text">
    <p>{{ isExpanded ? text : truncated }}</p>
    <button v-if="needsExpansion" @click="isExpanded = !isExpanded">
      {{ isExpanded ? 'Read less' : 'Read more' }}
    </button>
  </div>
</template>

<script setup>
import { ref, computed } from 'vue';

const props = defineProps({
  text:      { type: String, required: true },
  maxLength: { type: Number, default: 100 }
});

const isExpanded = ref(false);                              // local state
const needsExpansion = computed(() => props.text.length > props.maxLength);
const truncated = computed(() =>
  needsExpansion.value ? props.text.slice(0, props.maxLength) + '…' : props.text
);
</script>

Organizing Components

As an app grows, structure keeps it navigable. Two patterns cover most projects.

Base components

Highly reusable primitives β€” buttons, inputs, cards β€” often prefixed Base and kept together:

src/components/base/
β”œβ”€β”€ BaseButton.vue
β”œβ”€β”€ BaseInput.vue
β”œβ”€β”€ BaseCheckbox.vue
└── BaseCard.vue

Feature-based folders

Group everything for one domain together, so working on a feature means opening one folder:

src/components/
β”œβ”€β”€ base/          # shared UI primitives
β”œβ”€β”€ product/       # ProductCard, ProductDetails, ProductFilter…
β”œβ”€β”€ checkout/      # CartSummary, PaymentForm, ShippingForm…
└── user/          # ProfileCard, LoginForm, RegisterForm…

πŸ“– A peek ahead: slots

Props pass data down. Slots let a parent pass markup into a child β€” for example, a BaseCard that lets each caller fill in its own header and body. We cover slots in a later lesson; for now, just know props aren't the only way to configure a component.

Hands-on Exercise

πŸ‹οΈ Build a Validated ProductCard

Objective: Create a reusable, well-validated component and render a grid of it.

Instructions:

  1. Create ProductCard.vue with these props: name (String, required), price (Number, required), image (String, with a default placeholder), inStock (Boolean, default true), and rating (Number, with a validator forcing 1–5).
  2. Display the name, a currency-formatted price, an "In stock"/"Out of stock" line, and disable the "Add to cart" button when out of stock.
  3. In a parent, keep an array of at least three products and render a ProductCard for each with v-for and a unique :key.
  4. Try passing a rating of 7 and watch the validator warn in the console.
πŸ’‘ Hint

Currency formatting is cleanest with Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(price). The rating validator is (v) => v >= 1 && v <= 5. Remember: :key must be stable and unique β€” use product.id, never the array index if items can reorder.

βœ… Solution (ProductCard.vue)
<template>
  <article class="product-card">
    <img :src="image" :alt="name">
    <h3>{{ name }}</h3>
    <p class="price">{{ formatPrice(price) }}</p>
    <p :class="inStock ? 'ok' : 'out'">
      {{ inStock ? 'In stock' : 'Out of stock' }}
    </p>
    <button :disabled="!inStock">Add to cart</button>
  </article>
</template>

<script setup>
defineProps({
  name:    { type: String,  required: true },
  price:   { type: Number,  required: true },
  image:   { type: String,  default: '/images/placeholder.png' },
  inStock: { type: Boolean, default: true },
  rating:  { type: Number,  validator: (v) => v >= 1 && v <= 5 }
});

function formatPrice(value) {
  return new Intl.NumberFormat('en-US', {
    style: 'currency', currency: 'USD'
  }).format(value);
}
</script>

The parent imports ProductCard, keeps a ref([...]) of products, and loops with <ProductCard v-for="p in products" :key="p.id" v-bind="p" /> β€” v-bind="p" spreads every matching property as a prop.

🎯 Quick Quiz

Question 1: Which registration style is recommended for most components, and why?

Question 2: A child component receives a title prop and needs to display an edited version. What should it do?

Question 3: Why must an object or array prop default be returned from a factory function?

Best Practices

βœ… Do

  • Give every prop a type, and mark it required or give it a sensible default.
  • Keep components small and focused β€” one clear responsibility each.
  • Use multi-word component names (ProductCard, not Card) to avoid clashing with HTML elements.
  • Always provide a stable, unique :key when rendering with v-for.

⚠️ Don't

  • Don't mutate props inside a child β€” emit an event or copy into local state.
  • Don't reach for global registration unless a component truly appears everywhere.
  • Don't use an object/array literal directly as a prop default (share-by-reference bug).
  • Don't use the array index as a :key when the list can reorder or filter.

Summary & Quiz

πŸŽ‰ Key Takeaways

  • Components are reusable, self-contained UI pieces that form a tree.
  • Register components locally by default; reserve global registration for universal base components.
  • Props pass data from parent to child and should be validated with types, defaults, and custom validators.
  • Vue enforces one-way data flow; a child never mutates a prop β€” it copies into local state or emits an event.
  • Props are the component's public API; local state is its private workings.

πŸ“š Further Reading

πŸš€ What's Next?

You can now pass data down. Next we complete the loop with directives and event handling β€” how to make templates react to state and how children talk back to parents with emitted events.

πŸŽ‰ Nicely done!

Props are the backbone of every Vue app. Next, let's make components respond and communicate.