Prop Patterns

Props flow one way down. Keep a local copy when a child needs to edit, and spread a whole object of props with v-bind.

Syntaxconst draft = ref(props.title) // local copy; emit to commit

Props form a one-way-down binding. When the parent's value changes it flows into the child. The child cannot push a new value back up the same channel, and Vue makes the props object read-only to enforce it.

Why mutating a prop is a bug

Assigning to a prop is a dead end: the development build logs a warning and the value does not change, because the parent still owns it and will overwrite it on the next render. The fix is to emit an event and let the parent decide, or to use defineModel when the value is really two-way.

A local copy

Editing is the case where a child legitimately needs its own value. Copy the prop into a ref, edit the copy, and emit the result when the user commits. The copy is taken once, so add a watch on the prop if the parent can change it while the child is open.

Spreading props

When you already have an object whose keys match the child's props, v-bind without an argument passes all of them. These two lines do the same thing:

<article-card v-bind="article" />
<article-card :id="article.id" :title="article.title" />

What trips people up

Only the binding is read-only. A child can mutate a nested property of an object prop without any warning, and the parent sees the change with no record of where it came from. Treat object props as read-only by hand.

Example

Example · vue
<!-- src/components/TitleEditor.vue -->
<script setup lang="ts">
import { ref, watch } from 'vue'

const props = defineProps<{
  title: string
  saving?: boolean
}>()

const emit = defineEmits<{
  save: [title: string]
}>()

// A local copy. Editing the prop itself would be a no-op.
const draft = ref(props.title)

// The parent can still change the title while this form is open.
watch(
  () => props.title,
  (next) => {
    draft.value = next
  }
)

const dirty = ref(false)

function save() {
  emit('save', draft.value.trim())
  dirty.value = false
}
</script>

<template>
  <form @submit.prevent="save">
    <label for="title">Title</label>
    <input id="title" v-model="draft" type="text" @input="dirty = true" />
    <button type="submit" :disabled="saving || !dirty">Save</button>
  </form>
</template>

<style scoped>
form {
  display: grid;
  gap: 0.5rem;
}
</style>

When to use it

  • Build an inline edit field that only writes back to the parent when the user presses Save.
  • Pass a row object straight into a card component instead of listing eight bindings by hand.
  • Track down why a child's assignment to a prop appears to do nothing.
  • Stop a child from quietly mutating a settings object that the parent also reads.

More examples

The mutation, and the two fixes

A button whose label is the current count; each click asks the parent to raise it, and the new value arrives back as a prop. The parent writes <stepper v-model:count="total" />, because v-model with an argument is exactly a count prop plus an update:count listener. The second fix is defineModel, which writes this pair for you.

Example · vue
<!-- src/components/Stepper.vue -->
<script setup lang="ts">
const props = defineProps<{ count: number }>()

const emit = defineEmits<{
  'update:count': [value: number]
}>()

function increment() {
  // props.count++
  //   Read-only. The dev build logs a warning and nothing changes.

  // Fix one: hand the new value to the owner.
  emit('update:count', props.count + 1)
}
</script>

<template>
  <button type="button" @click="increment">Count: {{ props.count }}</button>
</template>

Spreading an object of props

v-bind with no argument passes every key of the object as a prop. It pays off when a list row maps one to one onto a child's props. Two cautions: a key the child has not declared becomes a fallthrough attribute on the child's root element instead of a prop, and when a key appears both in the object and as a separate binding the last one written wins.

Example · vue
<!-- src/components/ArticleList.vue -->
<script setup lang="ts">
import ArticleCard from './ArticleCard.vue'

interface Article {
  id: number
  title: string
  author: string
  minutes: number
}

const articles: Article[] = [
  { id: 1, title: 'Reactivity in depth', author: 'Ada', minutes: 9 },
  { id: 2, title: 'Props and events', author: 'Grace', minutes: 6 }
]
</script>

<template>
  <ul>
    <li v-for="article in articles" :key="article.id">
      <!-- these two are the same -->
      <article-card v-bind="article" />
      <article-card
        :id="article.id"
        :title="article.title"
        :author="article.author"
        :minutes="article.minutes"
      />
    </li>
  </ul>
</template>

Why the copy has to be explicit

Plain JavaScript showing the ownership rule that the warning is enforcing. The child's edit lives in the child until an event carries it upward. This is why a form built on props always has two values in play: the committed one from the parent and the in-progress one in the child.

Example · ts
// Inside a child component, conceptually:
const props = { title: 'Draft' } // read-only, owned by the parent

// A local copy is a new value you own.
let draft = props.title
draft = 'Draft (edited)'

console.log(props.title)
// Output: Draft
console.log(draft)
// Output: Draft (edited)

// The parent only learns about `draft` if you emit it.

Discussion

  • Be the first to comment on this lesson.