Props are how a parent passes data to a child. They are declared in the child, set by the parent, and they flow one way.
Declaring props
defineProps({
icon: { type: String, default: "bi-inbox" },
title: { type: String, required: true },
message: { type: String, default: "" },
});defineProps is a compiler macro, not a function you import. It only exists inside
<script setup>, and the compiler removes it — which is why there is no import for
it and why it must be called at the top level, never inside a condition.
Each entry declares a name, a type, and optionally required or a
default.
Passing them
<EmptyState
icon="bi-search"
title="Nothing matched"
message="Try a broader term, or clear the filters."
/>A plain attribute passes a string. To pass anything else — a number, a boolean, an object, a variable — bind it:
<ReelPlayer :reel="reel" :active="index === activeIndex" :muted="muted" />
<LoadingSpinner :compact="true" />
<LoadingSpinner compact /> <!-- shorthand for :compact="true" -->title="1" is the string "1"; :title="1" is the number.
That distinction accounts for a lot of early confusion.
Types are runtime checks, not TypeScript
The type field is a development-time validation. Pass the wrong type and Vue logs a
warning; it does not stop the render, and in production the check is stripped entirely.
Valid types are constructors — String, Number, Boolean,
Array, Object, Function, Date, or a class of your
own. An array of them means "any of these".
In a TypeScript project you would use type-only declarations instead, and get real compile-time checking:
// TypeScript. The demo application is plain JavaScript, so it uses the
// runtime form above -- this is what the typed equivalent looks like.
const props = defineProps<{
reel: Reel;
active?: boolean;
}>();Object and array defaults must be functions
defineProps({
modelValue: { type: Array, default: () => [] }, // correct
// default: [] // WRONG -- shared by every instance
});Same reason as in React or any class-based framework: a literal in a declaration is created once and shared by every component instance. A factory function makes a fresh one each time. Vue warns about this, but the warning is easy to scroll past.
Props are one-way and read-only
The parent owns the value. A child that writes to a prop gets a warning, and in the case of an object prop it silently mutates the parent's data — which is worse, because it works.
const props = defineProps({ title: String });
props.title = "new"; // warning: props are readonlyWhat to do instead, depending on what you actually want:
A local copy the child owns. Seed a ref from the prop:
const draft = ref(props.title);Note this takes the value once. If the prop changes later, draft does not
follow — which is usually the point, and occasionally a bug. Watch the prop if you need it to
follow.
A derived value. Use a computed, which does follow:
const hasVideo = computed(() => Boolean(props.reel.video?.url));The parent should change it. Emit an event and let the parent decide — the next lesson.
Reading a prop in script versus in a template
In the template, props are available by name. In script you need the object
defineProps returns:
const props = defineProps({ reel: { type: Object, required: true } });
const hasVideo = computed(() => Boolean(props.reel.video?.url));If you never touch the props in script, you can skip the assignment entirely — as
EmptyState and ReelCard both do.
Do not destructure the props object if you need reactivity:
const { reel } = defineProps({ reel: Object }); // `reel` is now a snapshotSame failure as destructuring a reactive in lesson 5, and just as silent. (Vue 3.5
added an opt-in compiler transform that makes destructured props reactive; unless you have explicitly
enabled it, assume the rule above.)
Casing
Declare props in camelCase; pass them in templates as kebab-case. Vue
maps between them:
<PaginationBar :total-pages="page.totalPages" :total-elements="page.totalElements" />camelCase works in templates too, and mixing the two in one codebase is the actual problem. Pick kebab-case in templates — it matches how every real HTML attribute is written.
Attributes that are not props
Anything you pass that is not declared as a prop — class, id, a
data- attribute, an event listener — falls through to the root element of the child
automatically. That is why <EmptyState class="mt-4" /> works without
EmptyState declaring anything.
With multiple root elements Vue cannot guess where they go, so it warns and you bind
v-bind="$attrs" explicitly. inheritAttrs: false switches the behaviour off
when you want them somewhere other than the root — on the inner <input> of a
wrapper component, typically.
Next: Events and Emits.