Props: data from above
In short: Props are the data a component gets from its parent, the way a function gets parameters; the child does not change them, and slots pass in content from outside.
Problem
Bread three times
The catalog must show three products: bread, milk and cheese. The product card is ready from the last lesson, so App puts it in three times.
Console
All three cards are bread. The product is written inside ProductCard, and every card shows it. The other way is BreadCard, MilkCard and CheeseCard: three copies of the same component, and every change to the card has to be made three times. What is missing is a way to tell the card which product to show.
The idea
Props: data from above
A component is a function. A function gets its data as parameters, and a component gets them as attributes from its parent: the component that uses it. These attributes are called props (from properties). The parent writes them, the child reads them.
<ProductCard :product="bread" /><ProductCard :product="milk" /><script setup>defineProps({ product: Object });</script>
<template> <h2>{{ product.name }}</h2></template>defineProps says which props the component takes. The template sees them directly, by name. In the script they are in the object that defineProps returns: const props = defineProps(…), and then props.product.name. The colon in :product is the same as for any attribute: the value is an expression, not text.
There is one card, and every use has different data. A function works the same way: one definition, many calls with different arguments.
See it
See how they flow
<script setup>defineProps({ product: Object, discount: { type: Number, default: 0 },});</script>
<template> <article> <h2>{{ product.name }}</h2> <p>Price: {{ money(product.price * (1 - discount / 100)) }}</p> </article></template>
<!-- App.vue --><script setup>const bread = { name: 'White bread', price: 1.2 };const milk = { name: 'Fresh milk', price: 1.6 };const cheese = { name: 'White cheese', price: 7.9 };</script>
<template> <main> <h1>Bread and milk</h1> <div> <ProductCard :product="bread" /> <ProductCard :product="milk" /> <ProductCard :product="cheese" :discount="10" /> </div> </main></template>Step by step, in words
- App holds the data: three products. Below it are three ProductCards: the same component three times, and none of them knows yet what to show.
- :product="bread" hands the object bread to the first card. The attributes of a component are called props.
- The other two cards get their own product. The third one also gets discount = 10. Each card has its own props.
- Each card draws itself from its props. Bread and milk got no discount, so it is 0 by default. The cheese is 10% off: €7.11.
- If the card tries props.discount = 0, nothing goes up: Vue warns "target is readonly" and does not make the change. App’s data stay the same.
- Data flow one way only: from the parent down to the child. To change something above, the child has to ask the parent. That is the module "State".
Every card knows only its own props. It does not see the other cards, and it does not know where the data in App come from.
See it
One card, three products
Console
The products are in App, and there is one ProductCard. Add a fourth product in App (the image img/apples.svg exists) and one more <ProductCard … />. You do not touch the card. With 30 products you will want a list rather than 30 lines: that is the next lesson.
Try it
Find the bug
The card does not show, and the console says "TypeError: Cannot read properties of undefined (reading 'toFixed')".
Pick the line with the bug.
product="bread" without a colon passes the text "bread", not the object. Text has no price, so product.price is undefined and money fails on toFixed. The object is passed with a colon: :product="bread". If the card said product: Object, Vue warns even before that: "Invalid prop: type check failed for prop "product". Expected Object, got String".The idea
A default value
Some products are on sale. The card gets a second prop, discount, in percent. Most products have no discount, and it is not nice for every line in App to write discount={0}. A missing prop is undefined, like a missing function argument. The price becomes price * (1 - undefined / 100), which is NaN.
So the prop gets a default value:
defineProps({ product: Object, discount: { type: Number, default: 0 },});default is the value when the parent passes nothing. type is a check: if you pass discount="10" (text, no colon), Vue warns in the console "Invalid prop: type check failed". The check runs only in development mode, and it only warns.
Try it
A discount
App gives the cheese a discount of 10 (percent). Make the card take discount, 0 by default, and show the price with the discount: the bread €1.20, the milk €1.60, the cheese €7.11.
Console
Solution
ProductCard.vue<script setup>
defineProps({
product: Object,
discount: { type: Number, default: 0 },
});
const money = (x) => '€' + x.toFixed(2);
</script>
<template>
<article>
<h2>{{ product.name }}</h2>
<p>Price: {{ money(product.price * (1 - discount / 100)) }}</p>
</article>
</template>
App.vue<script setup>
import ProductCard from './ProductCard.vue';
const bread = { name: 'White bread', price: 1.2 };
const milk = { name: 'Fresh milk', price: 1.6 };
const cheese = { name: 'White cheese', price: 7.9 };
</script>
<template>
<main>
<h1>Bread and milk</h1>
<div>
<ProductCard :product="bread" />
<ProductCard :product="milk" />
<ProductCard :product="cheese" :discount="10" />
</div>
</main>
</template>
style.cssbody { font-family: system-ui, sans-serif; margin: 16px; }
h1 { font-size: 22px; margin: 0 0 12px; }
main > div { display: flex; flex-wrap: wrap; gap: 12px; }
article { border: 1px solid #ddd5c4; border-radius: 8px; padding: 8px 12px; width: 140px; }
article img { width: 56px; height: 56px; }
h2 { font-size: 16px; margin: 4px 0; }
p { margin: 4px 0; }
.card { border: 1px solid #ddd5c4; border-radius: 8px; background: #fbf8f2; padding: 4px 12px; margin-top: 12px; max-width: 320px; }
button { font: inherit; margin-left: 8px; }
discount: { type: Number, default: 0 } in defineProps and the price money(product.price * (1 - discount / 100)). With discount: Number and no default, the bread and the milk show "€NaN": the check looks at all three prices.
The idea
Read-only
The data belong to whoever holds them. The card can read its props, but it cannot change them. Try the "One more" button: the child tries to increase its own qty.
Console
The quantity stays 2, and the console says "[Vue warn] Set operation on key "qty" failed: target is readonly.". Vue gives the component read-only props and warns on an attempt to write.
Even if it went through, the change would be only in the child: App would still say 2, and on the next drawing App would pass 2 again. Two places with different truths are the bug from "A list from data". The rule: data flow down, and changing them is the parent’s job. How a child asks its parent to change something is the module "State".
The idea
Content from outside
The shop has frames that look the same with different things inside: "Delivery" with two paragraphs, "Returns" with one. The title is a prop. The content is a whole piece of HTML, and it is handier to write it between the opening and the closing tag, as with <section>.
<Card title="Delivery"> <p>Within 2 working days.</p></Card><template> <section class="card"> <h2>{{ title }}</h2> <slot /> </section></template>The content between <Card> and </Card> goes where <slot /> is. Card decides where the slot is.
Console
Try it
The frame
Card shows only the title. Make it also show the content that App writes between <Card> and </Card>, under the title.
Console
Solution
Card.vue<script setup>
defineProps({ title: String });
</script>
<template>
<section class="card">
<h2>{{ title }}</h2>
<slot />
</section>
</template>
App.vue<script setup>
import Card from './Card.vue';
</script>
<template>
<main>
<h1>The cart</h1>
<Card title="Delivery">
<p>Within 2 working days.</p>
<p>Free over €30.</p>
</Card>
<Card title="Returns">
<p>Within 14 days of delivery.</p>
</Card>
</main>
</template>
style.cssbody { font-family: system-ui, sans-serif; margin: 16px; }
h1 { font-size: 22px; margin: 0 0 12px; }
main > div { display: flex; flex-wrap: wrap; gap: 12px; }
article { border: 1px solid #ddd5c4; border-radius: 8px; padding: 8px 12px; width: 140px; }
article img { width: 56px; height: 56px; }
h2 { font-size: 16px; margin: 4px 0; }
p { margin: 4px 0; }
.card { border: 1px solid #ddd5c4; border-radius: 8px; background: #fbf8f2; padding: 4px 12px; margin-top: 12px; max-width: 320px; }
button { font: inherit; margin-left: 8px; }
<slot /> under the title. A named slot (<slot name="content" />) does not work: App gives no name, so the content goes to the default slot, and there is none. If you put the slot inside <h2>, the paragraphs become part of the heading, and it is no longer "Delivery".
More precisely, if you are curious
Named slots. A frame can have several places: <slot /> for the main part and <slot name="footer" /> for the bottom. The parent fills a named slot with <template #footer>…</template>.
Names. In the script a prop is camelCase (productId), and in the parent’s template it may also be kebab-case (:product-id="7"). Vue connects them.
Props as variables. Since Vue 3.5 you can write const { product, discount = 0 } = defineProps(['product', 'discount']): the default value works as in JavaScript, and the variables stay connected to the parent.
Only the first level is frozen. props.qty = 3 warns, but props.product.price = 2 goes through silently: Vue guards only the first level against writes, and the product object is the same one as in App. That way the child changes the parent’s data behind its back. Do not do it.
Types. Besides type and default there is required: true (a warning if the parent does not pass the prop) and validator (a function that checks the value). All of it runs only in development mode. With TypeScript the types are written in defineProps<{ … }>().
Exercise
The shop window
Build the shop window from scratch. ProductCard takes product and discount (0 by default) and shows the image (alt is the name), the name as a level-two heading and "Price: …" with the discount. Card takes title and shows the title (level two) and the content from outside. In App, under the heading: the three cards in a div (the cheese with discount 10), then a Card "Delivery" with "Within 2 working days." and "Free over €30.".
Console
Solution
App.vue<script setup>
import ProductCard from './ProductCard.vue';
import Card from './Card.vue';
const bread = { name: 'White bread', price: 1.2, image: 'img/bread.svg' };
const milk = { name: 'Fresh milk', price: 1.6, image: 'img/milk.svg' };
const cheese = { name: 'White cheese', price: 7.9, image: 'img/cheese.svg' };
</script>
<template>
<main>
<h1>The shop window</h1>
<div>
<ProductCard :product="bread" />
<ProductCard :product="milk" />
<ProductCard :product="cheese" :discount="10" />
</div>
<Card title="Delivery">
<p>Within 2 working days.</p>
<p>Free over €30.</p>
</Card>
</main>
</template>
ProductCard.vue<script setup>
defineProps({
product: Object,
discount: { type: Number, default: 0 },
});
const money = (x) => '€' + x.toFixed(2);
</script>
<template>
<article>
<img :src="product.image" :alt="product.name" />
<h2>{{ product.name }}</h2>
<p>Price: {{ money(product.price * (1 - discount / 100)) }}</p>
</article>
</template>
Card.vue<script setup>
defineProps({ title: String });
</script>
<template>
<section class="card">
<h2>{{ title }}</h2>
<slot />
</section>
</template>
style.cssbody { font-family: system-ui, sans-serif; margin: 16px; }
h1 { font-size: 22px; margin: 0 0 12px; }
main > div { display: flex; flex-wrap: wrap; gap: 12px; }
article { border: 1px solid #ddd5c4; border-radius: 8px; padding: 8px 12px; width: 140px; }
article img { width: 56px; height: 56px; }
h2 { font-size: 16px; margin: 4px 0; }
p { margin: 4px 0; }
.card { border: 1px solid #ddd5c4; border-radius: 8px; background: #fbf8f2; padding: 4px 12px; margin-top: 12px; max-width: 320px; }
button { font: inherit; margin-left: 8px; }
ProductCard: defineProps({ product: Object, discount: { type: Number, default: 0 } }), <img :src="product.image" :alt="product.name" />, <h2>{{ product.name }}</h2> and the price with the discount. Card: defineProps({ title: String }), <section class="card">, <h2>{{ title }}</h2> and <slot />. App: <ProductCard :product="cheese" :discount="10" /> and the content between <Card title="Delivery"> and </Card>. The check finds the images by alt, the prices of the three cards and the paragraphs inside .card.
Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: Text, links, images, tables · Accessibility · A list from data · The first component
- Continues in: Lists and keys · Conditions: empty, error, loading · State · Derived state
- Tasks on this topic: Components