Vue – Props

November 5, 20254 min readUpdated 8/24/2026

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 readonly

What 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 snapshot

Same 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.