π§© 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.
β Why components matter
- Reusability β write a
ProductCardonce, 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.
π‘ 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:
| Props | Local state (ref / reactive) |
|---|---|
| Passed in from the parent | Created inside the component |
| Read-only from the child's view | Freely readable and writable |
| Change when the parent changes them | Changes are owned by this component |
| Define the component's public API | Define 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:
- Create
ProductCard.vuewith these props:name(String, required),price(Number, required),image(String, with a default placeholder),inStock(Boolean, defaulttrue), andrating(Number, with a validator forcing 1β5). - 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.
- In a parent, keep an array of at least three products and render a
ProductCardfor each withv-forand a unique:key. - Try passing a
ratingof7and 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 itrequiredor give it a sensibledefault. - Keep components small and focused β one clear responsibility each.
- Use multi-word component names (
ProductCard, notCard) to avoid clashing with HTML elements. - Always provide a stable, unique
:keywhen rendering withv-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
:keywhen 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.