Skip to main content

script setup and the component contract — declaring props and emits as types

Splitting components creates a boundary between them: what you receive from the parent, and what you tell the parent. Being able to write that boundary as types is the advantage of script setup.

The declarations use defineProps, defineEmits, and defineExpose. None of them need importing. They are macros processed at compile time, not functions that exist at runtime.

What you'll learn in this chapter​

  • Declare defineProps with types, and write default values through destructuring
  • Understand what to watch for when passing a destructured prop to an external function
  • Declare event names and arguments as types with defineEmits
  • Judge the means of reaching into a child from the parent, and when to use them
  • Explain where attributes passed from the parent end up

How the macros work​

Chapter 1 touched on how top-level declarations are usable from the template, with no return and no component registration.

That works because the compiler sees the template and the script together. It knows what the template refers to, so it can decide what to expose automatically. The three macros in this chapter sit on the same machinery. They are not calls to functions that exist at runtime; they are rewritten into code at compile time, so you cannot put them inside a conditional or assign them to a variable.

defineProps — receiving from the parent​

Declare with types and they become the subject of type checking directly.

components/UserCard.vue
<script setup lang="ts">
type Props = {
name: string
age?: number
role?: 'admin' | 'member'
}

// from Vue 3.5 onwards, destructuring preserves reactivity
const { name, age = 0, role = 'member' } = defineProps<Props>()
</script>

<template>
<p>{{ name }} ({{ age }} / {{ role }})</p>
</template>

You can write default values inline, as in age = 0. You can also rename with the role: userRole form.

How this differs from 3.4 and earlier​

Destructuring became reactive in Vue 3.5. Before that, destructuring produced a copy of the value at that moment and did not follow changes to the prop. Default values also had to be passed separately through withDefaults.

// the pre-3.4 form
const props = withDefaults(defineProps<Props>(), {
age: 0,
role: 'member',
})

If you see this form in existing code, you can move it to destructuring on 3.5 and later. If somewhere passes the whole props object to another function, though, leaving it as-is works fine.

Passing a destructured prop outward​

This is the thing to watch. A destructured variable follows changes in the template and in expressions inside the component, but passing it as a function argument produces a copy of the value.

Passing it directly to watch() is caught by the compiler, which stops you.

const { userId } = defineProps<{ userId: number }>()

// this is a compile error
watch(userId, () => { /* ... */ })
[@vue/compiler-sfc] "userId" is a destructured prop and should not be passed
directly to watch(). Pass a getter () => userId instead.

Wrapping it in a getter gets it through.

watch(() => userId, () => { /* ... */ })

But the behaviour changes with the type the getter returns. Primitives pick up changes, while a getter returning an object does not fire on nested changes because the reference does not change (the same story as deep in chapter 5). To react to the contents of an object, either add deep or have the getter descend to the property you need.

When passing it to your own function, the outcome depends on what that function accepts.

// a function taking a value: type-checks fine, receives a copy, does not follow
const useUserData = (id: number) => { /* ... */ }
useUserData(userId)

// a function taking a getter: passing a value fails to type-check
const useUserFeed = (getId: () => number) => { /* ... */ }
useUserFeed(() => userId)

The compiler detects two functions by name: watch() and toRef(). For anything else the only signal is a type mismatch, so design composables to take getters. Then a call that passes a value fails type checking.

The reason is the same as destructuring in chapter 2. What travels as a function argument is the value itself, and Vue cannot follow a value once it has been taken out. Wrapping in a getter defers the moment of reading, so you get the value as of when it is called.

props are read-only​

You cannot overwrite a value received from the parent inside the child. How you are stopped, though, varies by route.

Assigning to a destructured variable is a compile error.

[@vue/compiler-sfc] Cannot assign to destructured props as they are readonly.

Overwriting through the props object only warns in the development build. In a production build it passes through silently. That protection is also shallow, so overwriting a nested property such as props.user.name = 'x' gets through even in the development build and mutates the parent's object directly.

To change a value, ask the parent through defineEmits, next.

defineEmits — notifying the parent​

Declare event names and arguments as types.

components/CountButton.vue
<script setup lang="ts">
// the named tuple syntax from 3.3 onwards
const emit = defineEmits<{
increment: []
update: [value: number]
submit: [name: string, force: boolean]
}>()

const handleClick = () => {
emit('increment')
emit('update', 42)
emit('submit', 'name', true)
}
</script>

<template>
<button @click="handleClick">Send</button>
</template>

What is inside the square brackets is the argument types. An event with no arguments gets an empty array. With this declaration, a call with the wrong types such as emit('update', 'text') fails at compile time.

The parent receives them as ordinary events.

<template>
<!-- the first parameter is the value passed to emit -->
<CountButton @update="onUpdate" @submit="onSubmit" />
</template>

As covered in chapter 4, for a component's events the first parameter holds the emit arguments. Unlike a DOM element, no event object is passed.

defineExpose — exposing to the outside​

A component using script setup is closed by default. Even if the parent grabs the child through a template ref, it cannot reach the child's variables or functions.

Pass only what you want to expose to defineExpose.

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

const isPlaying = ref(false)

const play = () => {
isPlaying.value = true
}

// functions used only internally are not passed
const logState = () => console.log(isPlaying.value)

defineExpose({ isPlaying, play })
</script>

On the exposed object, refs are unwrapped automatically. The parent reads them without writing .value.

// on the parent side
playerRef.value?.play()
console.log(playerRef.value?.isPlaying) // no .value needed

Narrowing what you expose reduces dependence on the child's internal structure. Anything exposed is assumed to be used by the parent, so changing it means looking at the parent too.

Template refs​

To grab a DOM element or a child component, use useTemplateRef(). It arrived in Vue 3.5 and is the recommended approach in script setup.

components/SearchForm.vue
<script setup lang="ts">
import { onMounted, useTemplateRef } from 'vue'

// bound to ref="input" in the template
const inputRef = useTemplateRef('input')

onMounted(() => {
inputRef.value?.focus()
})
</script>

<template>
<input ref="input" />
</template>

The return value is a read-only shallow ref, and it is null before mount. Touching it before onMounted gives null, so use ?. or an existence check.

Handling types​

For a ref to a statically placed component, the type is inferred from 3.5 onwards. Manual casting is needed when grabbing a dynamic component.

import Foo from './Foo.vue'
import Bar from './Bar.vue'

// which one lands there is not statically determined, so state it with a type argument
const compRef = useTemplateRef<InstanceType<typeof Foo> | InstanceType<typeof Bar>>('comp')

InstanceType cannot be used on a generic component. Use ComponentExposed from vue-component-type-helpers.

Keep its use narrow​

The official documentation asks you to go easy on direct access to child instances and to prefer props and emits. Grabbing a reference means the parent knows the child's internals, which blurs the boundary.

Limiting it to calling imperative DOM operations such as focus() and play() is the safe approach. Keep state exchange in props and emits.

Where attributes passed from the parent land​

An attribute passed from the parent that the child has not declared lands on the child's root element. This is separate machinery from the scoped attribute in chapter 1: that one is an identifier Vue assigns internally, this one is about where an attribute the parent passed explicitly ends up.

<!-- parent: class and data-mark are undeclared attributes -->
<UserCard name="Tanaka" class="highlight" data-mark="a" />

If the child returns a single root element, that element gets class="highlight" data-mark="a". It is the mechanism for passing undeclared attributes straight through, and it pays off when writing wrapper components.

When the child does not have a single element as its root, nothing lands anywhere. That covers not just multiple root elements but also returning text alone, or putting Teleport at the root. The development-build warning lists all three as conditions.

components/MultiRoot.vue
<template>
<!-- attributes passed from the parent land on neither -->
<div class="first">a</div>
<div class="second">b</div>
</template>

To receive attributes in a component with multiple root elements, bind $attrs explicitly.

<template>
<div class="first" v-bind="$attrs">a</div>
<div class="second">b</div>
</template>

Multiple root elements are themselves something Vue 3 made possible (Vue 2 required a single root element). It does leave the destination of attributes undecided, though, so choose between wrapping the outside in a div and stating $attrs explicitly.

Summary​

  • defineProps / defineEmits / defineExpose are compiler macros and are used without importing
  • From Vue 3.5 onwards, destructuring props preserves reactivity. Default values and aliases can both be written inside the destructuring
  • Wrap a destructured prop in a getter when passing it to a function. The compiler detects only watch() and toRef() by name, so design composables to take getters
  • The behaviour changes with the type the getter returns. A getter returning an object does not fire on nested changes
  • How you are stopped from overwriting props varies by route. Assigning to a destructured variable is a compile error; going through props is a development-build warning only, and nested properties get through even in the development build
  • withDefaults is the form for 3.4 and below
  • Write defineEmits with the named tuple syntax from 3.3 onwards. Events with no arguments get an empty array
  • A script setup component is closed by default. Only what you pass to defineExpose is visible from the parent. Refs are unwrapped on the exposed object
  • Get template refs with useTemplateRef(). The return value is a read-only shallow ref and is null before mount
  • Types for static component refs are inferred from 3.5 onwards. Dynamic components and generic components need types supplied manually
  • Undeclared attributes land on the child's root element. When there is no single root element (multiple roots / text only / Teleport) they land nowhere and a warning is issued, so bind $attrs explicitly
Related references