Skip to content

Props

Assignment

Martina Hostettler wrote after the board meeting. Members at the counter keep asking whether the drill is in, and since the catalog is made of cards, every card shows the same thing.

As a member, I want to see every thing in the catalog as a card with its name, category and availability, so that I can tell at a glance what I can borrow.

The acceptance criteria are:

  1. Every card shows the name and the category of the thing.
  2. A thing that is on loan is marked "On loan", all others "Available".
  3. The same card will later appear on the favorites page as well.

Learning goals

After this chapter you can

  • give a component props with defineProps and declare their types,
  • pass values as plain text or, with a colon, as expressions,
  • use required props and default values,
  • explain why data flow only from the parent to the child component,
  • decide whether a component gets single values or a whole object.

You need the template syntax from part 3 and the chapter on breaking down an interface, where the component ItemCard came into being.

In the last chapter you moved the card of a thing into its own component, ItemCard. The catalog uses it three times. Look at both files and predict what the preview shows before you open the example in the playground.

App.vue

App.vue
<script setup>
import ItemCard from './ItemCard.vue'
</script>
<template>
<h1>Catalog</h1>
<div class="grid">
<ItemCard />
<ItemCard />
<ItemCard />
</div>
</template>
<style>
.grid {
display: grid;
gap: 0.75rem;
grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr));
}
</style>

ItemCard.vue

ItemCard.vue
<template>
<article class="card">
<h2>Drill</h2>
<p class="category">Tools</p>
</article>
</template>
<style scoped>
.card {
padding: 0.75rem 1rem;
border: 1px solid #c8c8c8;
border-radius: 8px;
}
h2 {
margin: 0 0 0.25rem;
font-size: 1.1rem;
}
.category {
margin: 0;
color: #5f5f5f;
}
</style>

All three cards show the drill. The catalog is not to blame. ItemCard has the name and the category written into its template, and every card renders the same template. For one component to show different things in different places, it needs input from outside. In Vue this input is called props, short for properties.

A component with props works like a function with parameters. The function states which values it expects, and whoever calls it supplies them. For ItemCard these are the name and the category. The parent component, App.vue here, owns the data and hands them over when it uses the card.

Props flow from the parent to the child component At the top is the component App with the catalog. Three arrows lead down to three cards of the component ItemCard. Each arrow carries the props App passes, for example name equals Drill and category equals Tools. Each card shows only what it received. There is no arrow back up, the data flow only downward. App.vue owns the data name="Drill" category="Tools" ItemCard.vue Drill Tools name="Tent for four" category="Camping" ItemCard.vue Tent for four Camping name="Raclette oven" category="Kitchen" ItemCard.vue Raclette oven Kitchen props, downward only

In <script setup> you declare props with defineProps. You pass an object in which every key is a prop and the value describes what that prop expects.

ItemCard.vue
<script setup>
defineProps({
name: { type: String, required: true },
category: { type: String, required: true },
})
</script>

type: String says the prop expects text, and required: true makes it mandatory. In the template you use props like variables, so {{ name }} shows the name. You don't import defineProps. It is a macro that the Vue compiler resolves when it translates the component, which is why it only works in <script setup>.

The parent passes the values as attributes, just like on an HTML element.

App.vue

App.vue
<script setup>
import ItemCard from './ItemCard.vue'
</script>
<template>
<h1>Catalog</h1>
<div class="grid">
<ItemCard name="Drill" category="Tools" />
<ItemCard name="Tent for four" category="Camping" />
<ItemCard name="Raclette oven" category="Kitchen" />
</div>
</template>
<style>
.grid {
display: grid;
gap: 0.75rem;
grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr));
}
</style>

ItemCard.vue

ItemCard.vue
<script setup>
defineProps({
name: { type: String, required: true },
category: { type: String, required: true },
})
</script>
<template>
<article class="card">
<h2>{{ name }}</h2>
<p class="category">{{ category }}</p>
</article>
</template>
<style scoped>
.card {
padding: 0.75rem 1rem;
border: 1px solid #c8c8c8;
border-radius: 8px;
}
h2 {
margin: 0 0 0.25rem;
font-size: 1.1rem;
}
.category {
margin: 0;
color: #5f5f5f;
}
</style>

Each card now gets its own values, and one component shows three different things.

The second acceptance criterion needs a third prop that says whether a thing is available. That is a truth value, so its type is Boolean.

ItemCard.vue
<script setup>
defineProps({
name: { type: String, required: true },
category: { type: String, required: true },
available: { type: Boolean, default: true },
})
</script>
<template>
<article class="card">
<h2>{{ name }}</h2>
<p class="category">{{ category }}</p>
<p v-if="available" class="state free">Available</p>
<p v-else class="state lent">On loan</p>
</article>
</template>

Passing it is where the most common mistake with props happens. An attribute without a colon always passes text. available="false" passes the text "false", and any text that isn't empty counts as true in JavaScript. Only with the colon does Vue evaluate the content as a JavaScript expression, so :available="false" passes the boolean false. The same goes for numbers, arrays, objects and variables. The colon is the shorthand for v-bind that you know from part 3.

App.vue
<div class="grid">
<ItemCard name="Drill" category="Tools" />
<ItemCard name="Tent for four" category="Camping" :available="false" />
<ItemCard name="Raclette oven" category="Kitchen" :available="true" />
</div>
In the templateWhat the component receives
name="Drill"the text "Drill"
:available="false"the boolean false
:deposit="20"the number 20
:name="item.name"the value of item.name
available without a valuetrue, for props of type Boolean only

The prop available has the default value true. If the parent leaves it out, Vue uses this value, which is why the drill shows as available even though App.vue says nothing about it. A prop is either required or has a default, both at once make no sense.

If a required prop is missing or has the wrong type, Vue still renders the component and writes a warning to the browser console, such as Missing required prop: "category". This check only runs during development, a production build checks nothing. So the declarations in defineProps don't protect the app at runtime. They help while you develop, and they document what a component needs, for you and for anyone who uses it later.

For objects and arrays the default must be a function that returns a new object every time, for example default: () => []. Otherwise all cards would share the same array. With validator you can add a check of your own, for instance that a number isn't negative, with validator: (value) => value >= 0.

Deep dive (optional) Destructuring props

Since Vue 3.5 you can destructure props into variables and set the default right there.

ItemCard.vue
<script setup>
// Since Vue 3.5 destructured props stay reactive, the default moves into the destructuring
const { name, category, available = true } = defineProps({
name: { type: String, required: true },
category: { type: String, required: true },
available: { type: Boolean },
})
</script>

The card stays reactive, because the compiler turns name back into props.name behind the scenes. If you pass such a variable on to another function, though, that function only gets the current value. For watch from part 5 you therefore write watch(() => available, ...). The course mostly uses const props = defineProps(...), because it keeps visible what is a prop.

Props are a one-way street. The parent owns the data and passes them down, the child only reads them. When a value in App.vue changes, Vue renders the affected cards again. The card itself must not change its props.

<script setup>
const props = defineProps({ available: Boolean })
function reserve() {
props.available = false // blocked, Vue warns that props are readonly
}
</script>

Vue blocks this assignment and warns in the console that the prop is readonly. The reason is traceability. If every card could change the catalog's data, soon nobody would know where a value came from. This way exactly one component owns and changes each value.

If the card needs a value of its own that it changes, such as the number of days someone wants to borrow a thing, it keeps its own state with ref. If it needs to tell the parent something, such as that someone clicked "Reserve", it emits an event. That is the subject of the next chapter.

The catalog keeps its things in an array, and v-for creates a card for each of them. Instead of passing name, category and availability one by one, you can also pass the whole object as a single prop.

App.vue

App.vue
<script setup>
import { ref } from 'vue'
import ItemCard from './ItemCard.vue'
const items = ref([
{ id: 1, name: 'Drill', category: 'Tools', available: true },
{ id: 2, name: 'Tent for four', category: 'Camping', available: false },
{ id: 3, name: 'Raclette oven', category: 'Kitchen', available: true },
{ id: 4, name: 'Beer bench set', category: 'Parties', available: true },
])
</script>
<template>
<h1>Catalog</h1>
<div class="grid">
<ItemCard v-for="item in items" :key="item.id" :item="item" />
</div>
</template>
<style>
.grid {
display: grid;
gap: 0.75rem;
grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr));
}
</style>

ItemCard.vue

ItemCard.vue
<script setup>
defineProps({
item: { type: Object, required: true },
})
</script>
<template>
<article class="card">
<h2>{{ item.name }}</h2>
<p class="category">{{ item.category }}</p>
<p v-if="item.available" class="state free">Available</p>
<p v-else class="state lent">On loan</p>
</article>
</template>
<style scoped>
.card {
padding: 0.75rem 1rem;
border: 1px solid #c8c8c8;
border-radius: 8px;
}
h2 {
margin: 0 0 0.25rem;
font-size: 1.1rem;
}
.category {
margin: 0;
color: #5f5f5f;
}
.state {
margin: 0.5rem 0 0;
font-weight: 600;
}
.free {
color: #1f6b4f;
}
.lent {
color: #a3341f;
}
</style>

Both ways are common, and each has its strengths and weaknesses.

Single propsOne object as a prop
Interfaceshows exactly what the card needsdepends on the shape of the data
Reusefits data from another source too, such as a list of favoritesother data first need the same shape
Testsa few values are enougha matching object has to be built
Typingmore, hard to read with many fieldsless
Pitfallnone in particularfields of the object can be changed without a warning

Vue only protects the prop itself. It blocks props.item = ..., but not props.item.available = false, because the object stays the same one that App.vue points to. The change ends up in the parent's data without any warning. So if you pass an object, never change its fields in the child.

For small components that only display something, like the card, single props are usually the better choice. Components that work with a whole record, such as a form for editing a thing, are better served by an object.

Work with the availability example. Read the code first and write down what you expect before you change anything.

App.vue

App.vue
<script setup>
import ItemCard from './ItemCard.vue'
</script>
<template>
<h1>Catalog</h1>
<div class="grid">
<ItemCard name="Drill" category="Tools" />
<ItemCard name="Tent for four" category="Camping" :available="false" />
<ItemCard name="Raclette oven" category="Kitchen" :available="true" />
</div>
</template>
<style>
.grid {
display: grid;
gap: 0.75rem;
grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr));
}
</style>

ItemCard.vue

ItemCard.vue
<script setup>
defineProps({
name: { type: String, required: true },
category: { type: String, required: true },
available: { type: Boolean, default: true },
})
</script>
<template>
<article class="card">
<h2>{{ name }}</h2>
<p class="category">{{ category }}</p>
<p v-if="available" class="state free">Available</p>
<p v-else class="state lent">On loan</p>
</article>
</template>
<style scoped>
.card {
padding: 0.75rem 1rem;
border: 1px solid #c8c8c8;
border-radius: 8px;
}
h2 {
margin: 0 0 0.25rem;
font-size: 1.1rem;
}
.category {
margin: 0;
color: #5f5f5f;
}
.state {
margin: 0.5rem 0 0;
font-weight: 600;
}
.free {
color: #1f6b4f;
}
.lent {
color: #a3341f;
}
</style>
  1. What does the third card show if you remove :available="true" entirely? What does the second one show if you delete the colon there?
  2. Open the example in the playground and check your predictions.
  3. Remove the category from one card. Which warning appears in the playground's console, and what does the card show?
  4. Change the default of available to false. Which cards change, and why exactly those?

After that you build things yourself in the exercises.

The exercises build on a small project that shows the catalog of Lorraine Commons with six things. You find it in the course repository in the folder exercises/04-components/props/en. Copy the folder, install the packages and run the tests.

Terminal window
npm install
npm test

npm run dev shows the catalog in the browser. The tests in the folder tests tell you which exercises are done. At the start almost all of them fail, and that is intended.

Exercise Getting started

Lorraine Commons asks for a deposit for every thing. Give ItemCard a required prop deposit of type Number and show the deposit below the category, for example Deposit: CHF 20.00. toFixed(2) gives a number two decimal places. Then pass the value in App.vue. The tests in level-1.test.js check both.

Solution

ItemCard.vue gets the new prop, and the template shows it.

ItemCard.vue
const props = defineProps({
name: { type: String, required: true },
category: { type: String, required: true },
deposit: { type: Number, required: true },
availableFrom: { type: String, default: null },
})
ItemCard.vue
<template>
<article class="card">
<h2>{{ name }}</h2>
<p class="category">{{ category }}</p>
<p>Deposit: CHF {{ deposit.toFixed(2) }}</p>
<p class="state" :class="{ lent: availableFrom !== null }">{{ state }}</p>
</article>
</template>

In App.vue the value needs a colon, :deposit="item.deposit". Without it the card would receive the text "item.deposit", Vue would warn about the wrong type, and the first test would fail.

Exercise Going further

A thing that is on loan has a date in availableFrom in its record, otherwise the field is null. Give the card a prop availableFrom of type String with the default null. The card shows "Available" as long as there is no date and otherwise, for example, "Available again from 11/12/2026". Compute this text with computed from part 3. For computed to reach the prop you need const props = defineProps(...). You format the date with new Date(props.availableFrom).toLocaleDateString('en-US', { timeZone: 'UTC' }).

Solution
ItemCard.vue
<script setup>
import { computed } from 'vue'
const props = defineProps({
name: { type: String, required: true },
category: { type: String, required: true },
deposit: { type: Number, required: true },
availableFrom: { type: String, default: null },
})
// A date string such as 2026-11-12 counts as midnight UTC, so it is formatted in UTC as well
const state = computed(() => {
if (props.availableFrom === null) return 'Available'
const date = new Date(props.availableFrom).toLocaleDateString('en-US', { timeZone: 'UTC' })
return `Available again from ${date}`
})
</script>

JavaScript reads a date without a time, such as 2026-11-12, as midnight UTC. Formatted in the time zone of Switzerland it still shows the right day, but in the Americas it shows the day before. With timeZone: 'UTC' the day is right everywhere. Because state is a computed, Vue recalculates the text as soon as the prop changes, which the third test checks.

Exercise Challenge

Create a new component ItemList.vue. It receives the array of things as the required prop items, shows an ItemCard for every thing and passes on all four values. If the array is empty, it shows "No things found.". Use ItemList in App.vue. Then think about the favorites page from the assignment and why it pays off that ItemList gives the card single props instead of the whole object.

Solution
ItemList.vue
<script setup>
import ItemCard from './ItemCard.vue'
defineProps({
items: { type: Array, required: true },
})
</script>
<template>
<p v-if="items.length === 0" class="empty">No things found.</p>
<div v-else class="grid">
<ItemCard
v-for="item in items"
:key="item.id"
:name="item.name"
:category="item.category"
:deposit="item.deposit"
:available-from="item.availableFrom"
/>
</div>
</template>
App.vue
<script setup>
import ItemList from './components/ItemList.vue'
import { items } from './items.js'
</script>
<template>
<h1>Lorraine Commons</h1>
<ItemList :items="items" />
</template>

In its template ItemList writes the prop as available-from. Vue treats it as the same prop as availableFrom, templates accept both. Because the card only knows single values, the favorites page can feed it with data from another source, such as a list of reservations, without that data having the same shape as the catalog.

MistakeWhat helps
The child changes a prop, for example with props.available = false.Change the value in the parent and tell the parent with an event (next chapter).
deposit="20" without a colon, so the prop receives the text "20".Use the colon for everything that isn't fixed text. The type warning in the console points to the mistake.
The child changes fields of an object it received as a prop.Never change fields in the child. Tell the parent, or work with a copy.
Unsure whether available-from in the template and availableFrom in the script are the same prop.Vue treats both spellings as the same. Use camelCase in scripts, templates accept both.
An AI assistant writes props: { ... } and this.name inside <script setup>.That is the Options API. <script setup> has no this, props come from defineProps.
An AI assistant imports defineProps from vue.Drop the import. defineProps is a compiler macro, and the compiler warns about the import.
An AI assistant suggests the .sync modifier..sync comes from Vue 2. In Vue 3, v-model on components does that job, which the next chapter covers as well.

Self-check

  1. What is the difference between available="false" and :available="false"?
  2. A required prop is missing. What happens during development, and what in a production build?
  3. Why must the default of a prop of type Array be a function?
  4. A card receives an object item and sets item.available = false. Who sees this change, and why doesn't Vue warn?
Answers
  1. Without the colon the card receives the text "false", which counts as true in JavaScript. With the colon Vue evaluates the expression and passes the boolean false.
  2. During development Vue renders the component and warns in the console. A production build no longer checks props, the card simply stays empty where the missing prop would go.
  3. Otherwise all instances of the component would share the same array, and a change in one place would show up everywhere.
  4. Every component that uses the same object, so App.vue and every other place that shows this thing. Vue only protects the prop itself from being reassigned, not the fields of the object behind it.

Assignment

Martina Hostettler likes the cards and already has her next wish. Members should be able to reserve a thing right on its card. The button for that lives in ItemCard, but App decides on the pages of the app. Props carry data downward only, the way back up needs events. That is what the next chapter is about.

Text and figures are licensed under CC BY-SA 4.0, the code under the MIT License and the course data under CC0 1.0. The figures use the font Kalam under the SIL Open Font License 1.1. The sources are on GitHub.