Components and props
Split UI into child components, pass data down with defineProps(), and decide where extra attributes land.
What you'll learn
When you finish, you can do these.- 1Pass data from a parent to a child component with props
- 2Give optional props a default and use boolean props
- 3Send attributes you did not declare to the right element
- 4Fix a number passed as text and a prop the child changes
An online shop shows the same small badge, a rounded label, all over its pages: New on one product, Sale on another, a count of unread messages next to Inbox. Copy its HTML and CSS into each place, and every design change means fixing every copy. A component, one .vue file for one piece of the page, lets you write the badge once. Props, the inputs a component accepts, let each place hand it its own words, colour and number.
The Code tab holds one such page in three files: App.vue shows four badges and a search field, Badge.vue is the badge, written once, and TextField.vue is the search field. By the end you can read every line and add a badge of your own. You need one idea from the lesson Your first component: a colon before an attribute, as in :href="url", makes Vue read the quoted part as JavaScript, not text.
Pass data down with props
Props carry data one way, from the page down into each badge. The badge is the file Badge.vue, a child component: one used inside another. App.vue, the parent, imports it and writes it like an HTML tag, as in <Badge label="New" />. The attributes on that tag are the badge's props.
Inside the child, defineProps() declares which props exist:
<script setup lang="ts">
const { label, tone = 'cool', count = 0, disabled } = defineProps<{
label: string
tone?: 'cool' | 'warm'
count?: number
disabled?: boolean
}>()
</script>
Each line inside the braces names a prop and its type; tone takes one of two words, 'cool' or 'warm'. A prop without ? is required, and Vue warns when a parent leaves it out (Missing required prop: "label").
defineProps() is a compiler macro: Vue's compiler replaces it while building, so you never import it. The section Defaults and booleans explains the const { … } = in front.
The template of Badge.vue uses the props like any name from <script setup>:
<template>
<span :class="['badge', tone, { off: disabled }]">
{{ label }}
<b v-if="count > 0">{{ count }}</b>
</span>
</template>
It prints the label, then the count in bold; v-if shows the count only while it is above 0. tone adds the class cool or warm, a blue or an orange background, and disabled adds off, which greys the badge out.
The parent owns the values, and each child gets its own set. The diagram shows the same rule with two product cards:
In the parent, a plain attribute passes text, called a string. With a colon in front, Vue reads the quoted part as JavaScript: a number, a sum, a variable. The badges in App.vue use both:
<Badge label="New" />
<Badge label="Sale" tone="warm" :count="5" />
<Badge label="Inbox" :count="unread" />
<Badge label="Sold out" disabled />
label="New" passes the text New, and :count="5" the number 5. :count="unread" passes whatever unread holds right now, 3 at the start: unread is a ref in App.vue, a box that holds a value and tells Vue when it changes.
Click the New message button in the preview, which runs unread++, and the Inbox number goes up: a child re-renders when a prop it reads changes.
Defaults and booleans
An optional prop the parent leaves out is undefined, JavaScript's word for no value, unless you give it a default. The const { … } = line of Badge.vue destructures the props, taking each one out under its own name, and tone = 'cool' is the value used when the parent passes no tone. So the New badge is blue and, with count = 0, shows no number. The compiler keeps each name tied to the live prop, so it still follows the parent.
A prop typed boolean, true or false, works like the disabled attribute of an HTML button. Writing its name alone passes true: App.vue writes disabled on the Sold out badge, which is greyed out. Leaving it out passes false, not undefined, so a boolean prop needs no default.
Attributes you did not declare
Attributes that are not props still reach the child, and you can choose where they land. Write class="big" on a Badge, which has no class prop: it lands on the component's root element, the one element at the top of its template, here the <span>. These are called fallthrough attributes. class and style are merged with the root's own, and an @click becomes a click listener on the root.
That needs a single root element. With two elements side by side at the top, Vue warns Extraneous non-props attributes (class) were passed to component… and drops them.
Sometimes the root is the wrong place. TextField.vue, the Code tab's other child component, is a labelled text field: an <input> inside a <label>. App.vue uses it for the search field:
<TextField label="Search" placeholder="Mugs, caps, bags" class="wide" />
label is its one prop. The placeholder, the grey hint in an empty field, and the wide class fall through to the label, where a placeholder does nothing. Turn the automatic behaviour off and choose the element yourself:
<script setup lang="ts">
defineOptions({ inheritAttrs: false })
defineProps<{ label: string }>()
</script>
<template>
<label class="field">
{{ label }}
<input v-bind="$attrs" />
</label>
</template>
defineOptions() is another compiler macro, for settings of the whole component; inheritAttrs: false stops the label from receiving the extras. $attrs holds everything the parent passed that is not a prop, and v-bind="$attrs" puts it all on the input: the preview shows a wide field with Mugs, caps, bags in grey.
What goes wrong
The most common prop mistake passes the right value as the wrong type. Without a colon, Vue reads the value as text:
<Badge label="Sale" count="5" />
The badge still shows 5, but the preview warns Invalid prop: type check failed for prop "count". Expected Number with value 5, got String with value "5". The day Badge displays count + 1, it prints 51. Add the colon:
<Badge label="Sale" :count="5" />
The warning goes away: count is now the number 5.
The second mistake changes a prop inside the child. Picture a Clear button in Badge whose function sets count to 0. The component stops compiling:
function clear() {
count = 0 // Cannot assign to destructured props as they are readonly.
}
The preview shows that error and stops updating. Props are read-only in the child because the parent owns the value. Change the data where it lives, in the parent, as the Mark all read button of App.vue does:
<button @click="unread = 0">Mark all read</button>
Click it and the Inbox number disappears. A button inside the child would ask the parent with an event, the subject of the next lesson, Events and v-model.
What to remember
- Declare props in the child with
defineProps<{ … }>(). A prop without?is required, and Vue warns when it is missing. - A plain attribute passes a string. A colon, as in
:count="5", passes a number, a ref or any expression. - Destructure props to give optional ones a default (
tone = 'cool'). A boolean prop istruewhen written alone andfalsewhen left out. - Attributes you did not declare land on the single root element.
inheritAttrs: falsewithv-bind="$attrs"sends them to another element. - Props flow down and are read-only in the child. The parent changes its own state, and the child re-renders.
Try it
Open the Code tab and change the starter code. Each task says what you should see.- Add a third tone. In
Badge.vue, add'mint'to thetonetype and a.mint { background: #d5f0e3; }rule, then write<Badge label="Fresh" tone="mint" />inApp.vue. A green Fresh badge appears. - Give the Sale badge a live count. Add
const saleLeft = ref(5)inApp.vue, change the badge to:count="saleLeft", and add a Sell one button with@click="saleLeft--". Each click lowers the number, and at 0 it disappears. - Remove the colon from
:count="unread". The Inbox number disappears, and the preview warnsExpected Number with value NaN, got String with value "unread": Badge got the word unread, not the ref. Put the colon back and the count returns.