Skip to content
TutorialBeginner

Props down, events up: how components talk

Data flows one way in Vue: down through props, and back up through events. A TaskList and a TaskRow show the whole pattern, with typed props and typed emits.

vuejs.school team6 Feb 20255 min readVue 3.5

Once an app has more than one component, the components have to talk. Vue's basic pattern gives them two channels: a parent hands data down to a child through props, and a child sends a message up to the parent through an event.

You will build a task list (the parent) and a task row (the child) that use both, with TypeScript types on each, and see why the child must not change what it was given.

Build the parent that owns the tasks

The parent owns the data: an array of tasks. The child shows one task with a checkbox and a delete button. Here is the parent first, so you can see the shape before the details. It imports TaskRow.vue, which the next two sections write.

src/components/TaskList.vue
<script setup lang="ts">
import { ref } from 'vue'
import TaskRow from './TaskRow.vue'

interface Task {
  id: number
  title: string
  done: boolean
}

const tasks = ref<Task[]>([
  { id: 1, title: 'Read the lesson', done: true },
  { id: 2, title: 'Run the example', done: false },
  { id: 3, title: 'Take the quiz', done: false },
])

function toggle(id: number) {
  const task = tasks.value.find(t => t.id === id)
  if (task) task.done = !task.done
}

function remove(id: number) {
  tasks.value = tasks.value.filter(t => t.id !== id)
}
</script>

<template>
  <ul>
    <TaskRow
      v-for="task in tasks"
      :key="task.id"
      :id="task.id"
      :title="task.title"
      :done="task.done"
      @toggle="toggle"
      @remove="remove"
    />
  </ul>
</template>

Two things to notice. All the changes to tasks happen here, in toggle and remove. And the child receives three plain values, not the whole task object. That is a choice, and a good default: a child that gets only what it needs is easier to reuse and test.

Declare props with types

A prop is a value the parent passes in; it looks like an HTML attribute on the child's tag. Inside the child, defineProps() declares which props exist. It is a compiler macro, which means you do not import it and Vue's compiler turns it into real code.

src/components/TaskRow.vue
<script setup lang="ts">
const props = defineProps<{
  id: number
  title: string
  done: boolean
}>()
</script>

<template>
  <li>
    <input type="checkbox" :checked="props.done" />
    <span :class="{ finished: props.done }">{{ props.title }}</span>
    <button type="button">Delete</button>
  </li>
</template>

<style scoped>
.finished {
  text-decoration: line-through;
}
</style>

The type argument is the whole declaration. Each key is a prop:

  • Its type is checked where the parent uses the component: pass a string as id and your editor flags it.
  • A key without ? is required. Vue warns in development if the parent leaves it out.
  • A key with ? is optional. To give it a fallback, destructure a default: const { done = false } = defineProps<{ done?: boolean }>(). Vue 3.5 keeps a destructured prop reactive.

In the template you can write title on its own instead of props.title; both work, because Vue exposes top-level props to the template directly. This post keeps the props. prefix so it is always obvious which values came from the parent. Pick one style per project and stay with it.

Right now the row only displays. The checkbox and the button do not tell the parent anything yet.

Send changes up with typed events

The child cannot reach into the parent's tasks array, and it should not try. What it can do is announce that something happened. defineEmits(), another compiler macro, declares the events a component can send, and with TypeScript you give each event the type of its payload.

Here is TaskRow.vue again with the two events declared and wired to the checkbox and the button. The <style scoped> block stays as it was.

src/components/TaskRow.vue
<script setup lang="ts">
const props = defineProps<{
  id: number
  title: string
  done: boolean
}>()

const emit = defineEmits<{
  toggle: [id: number]
  remove: [id: number]
}>()
</script>

<template>
  <li>
    <input type="checkbox" :checked="props.done" @change="emit('toggle', props.id)" />
    <span :class="{ finished: props.done }">{{ props.title }}</span>
    <button type="button" @click="emit('remove', props.id)">Delete</button>
  </li>
</template>

The syntax toggle: [id: number] reads as "an event called toggle whose arguments are one number called id". Calling emit('toggle', 'two') is a type error. Calling emit('finish', 1) is a type error, because no such event is declared.

Back in the parent, @toggle="toggle" listens for the event and passes its payload to the handler. Click the checkbox: the row emits toggle with its id, the parent flips done on the right task, the new value flows down into the done prop, and the checkbox updates. Every change made a round trip, and the parent was in charge of it.

Why a child must not change a prop

A common first attempt is a shortcut: flip the prop inside the child.

src/components/TaskRow.vueBreaks
<script setup lang="ts">
const props = defineProps<{ id: number; title: string; done: boolean }>()

function toggleDone() {
  props.done = !props.done
}
</script>

Two checks catch this while you develop. With TypeScript the editor flags it first, because the props object is typed as read-only: Cannot assign to 'done' because it is a read-only property. If it gets past the types, Vue blocks the assignment in development and logs a warning in the console: [Vue warn] Set operation on key "done" failed: target is readonly.

A production build leaves that runtime check out, so there the assignment goes through without a warning. That is where you see why props are read-only on purpose. The row shows the new value while the parent's tasks array still holds the old one, and the next time any of that row's props change, Vue pushes the parent's value back down and silently undoes the child's change.

The fix is the event you already have:

src/components/TaskRow.vueWorks
<script setup lang="ts">
const props = defineProps<{ id: number; title: string; done: boolean }>()
const emit = defineEmits<{ toggle: [id: number] }>()

function toggleDone() {
  emit('toggle', props.id)
}
</script>

The parent flips done in tasks and the new value comes back down, so the row and the parent never disagree.

This one-way flow keeps an app understandable as it grows. To find out where a value can change, you look at one place: the component that owns it. Everything below it can only read and ask.

Shorten bindings with the same-name shorthand

Since Vue 3.4, when the attribute name and the variable name match, you can drop the value: :title="title" becomes :title. In a v-for you can destructure each item so the names line up. This is the template of TaskList.vue again, with the bindings written the short way:

src/components/TaskList.vue
<template>
  <ul>
    <TaskRow
      v-for="{ id, title, done } in tasks"
      :key="id"
      :id
      :title
      :done
      @toggle="toggle"
      @remove="remove"
    />
  </ul>
</template>

It is the same binding, just shorter. Use it when it reads clearly, which is most of the time when the names already match.

Decide between a prop and local state

The last question is the one you will ask on every component: should this value be a prop, or a ref inside the child?

Ask who needs to know about it. If the parent needs it, or any other component does, it is the parent's data and it comes down as a prop. If only the child cares, such as whether its own details panel is open, it is local state: a ref inside the child, changed by the child, with no event needed.

Two cases make it tempting to change a prop, and each has its own fix:

  • The prop is only a starting value, such as initialCount. Copy it into a local ref once: const count = ref(props.initialCount). After that the child owns count, and later changes to the prop are ignored on purpose.
  • The prop needs transforming, such as a size that should be lower-cased. Use a computed: const size = computed(() => props.size.toLowerCase()). It stays in step with the prop without copying it.

The Vue docs cover the same two cases, with the reasons behind them, in their section on one-way data flow.

Check it stuck3 questions from the Components and props quiz.
Practice now
Was this helpful?

Keep reading