4 min read
On this page

Props and Emits

Components communicate through props (data down) and emits (events up). Vue 3 with TypeScript makes this type-safe and concise with defineProps and defineEmits, which are compiler macros -- they look like function calls but are processed at build time and do not need to be imported.

defineProps with TypeScript

The cleanest way to define props is with a TypeScript-only declaration:

<script setup lang="ts">
const props = defineProps<{
  title: string
  count: number
  tags?: string[]
  disabled?: boolean
}>()
</script>

<template>
  <div :class="{ disabled }">
    <h2>{{ title }} ({{ count }})</h2>
    <span v-for="tag in tags" :key="tag" class="tag">{{ tag }}</span>
  </div>
</template>

Required props have no ?. Optional props use ?. TypeScript enforces this at compile time, so you get red squiggles in your IDE when a parent component forgets to pass title.

Default Values with withDefaults

Optional props need defaults. Use withDefaults:

<script setup lang="ts">
interface Props {
  title: string
  size?: 'sm' | 'md' | 'lg'
  tags?: string[]
  onClose?: () => void
}

const props = withDefaults(defineProps<Props>(), {
  size: 'md',
  tags: () => [],
})
</script>

Note: array and object defaults must be factory functions (() => []), not plain values. This prevents shared references between component instances -- the same reason React class components required function returns from getInitialState.

Runtime Props (Alternative Syntax)

You can also define props with the runtime declaration, which supports custom validators:

<script setup lang="ts">
const props = defineProps({
  email: {
    type: String,
    required: true,
    validator: (value: string) => value.includes('@'),
  },
  role: {
    type: String as () => 'admin' | 'user' | 'guest',
    default: 'guest',
  },
})
</script>

The runtime syntax runs validators at runtime (development mode only), which can catch issues that TypeScript cannot -- like a prop that passes type checking but has an invalid value. In practice, most teams use the TypeScript-only syntax and rely on types rather than runtime validators.

defineEmits

Events flow upward. defineEmits declares what events a component can emit and their payload types:

<script setup lang="ts">
const emit = defineEmits<{
  (e: 'update', id: number, value: string): void
  (e: 'delete', id: number): void
  (e: 'close'): void
}>()

function handleSave(id: number) {
  emit('update', id, 'new value')
}

function handleRemove(id: number) {
  emit('delete', id)
}
</script>

Vue 3.3 introduced a shorter syntax:

<script setup lang="ts">
const emit = defineEmits<{
  update: [id: number, value: string]
  delete: [id: number]
  close: []
}>()
</script>

Both syntaxes produce the same result. The tuple syntax is more compact and what most codebases have converged on.

The parent listens with @:

<template>
  <ItemCard
    :id="item.id"
    :title="item.title"
    @update="handleUpdate"
    @delete="handleDelete"
    @close="showPanel = false"
  />
</template>

v-model on Components

v-model on native inputs is shorthand for a value prop and an input event. On components, it works the same way but with modelValue as the prop name and update:modelValue as the event:

<!-- Parent -->
<script setup lang="ts">
import { ref } from 'vue'
import RatingInput from './RatingInput.vue'

const rating = ref(3)
</script>

<template>
  <RatingInput v-model="rating" />
  <p>Selected: {{ rating }} stars</p>
</template>
<!-- RatingInput.vue -->
<script setup lang="ts">
const props = defineProps<{
  modelValue: number
}>()

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

function select(stars: number) {
  emit('update:modelValue', stars)
}
</script>

<template>
  <div class="rating">
    <button
      v-for="n in 5"
      :key="n"
      :class="{ active: n <= modelValue }"
      @click="select(n)"
    >
      {{ n <= modelValue ? 'X' : 'O' }}
    </button>
  </div>
</template>

Named v-model

Vue 3 supports multiple v-models on a single component using named bindings:

<!-- Parent -->
<template>
  <UserForm
    v-model:first-name="first"
    v-model:last-name="last"
    v-model:email="email"
  />
</template>
<!-- UserForm.vue -->
<script setup lang="ts">
const props = defineProps<{
  firstName: string
  lastName: string
  email: string
}>()

const emit = defineEmits<{
  'update:firstName': [value: string]
  'update:lastName': [value: string]
  'update:email': [value: string]
}>()
</script>

<template>
  <input :value="firstName" @input="emit('update:firstName', ($event.target as HTMLInputElement).value)" />
  <input :value="lastName" @input="emit('update:lastName', ($event.target as HTMLInputElement).value)" />
  <input :value="email" @input="emit('update:email', ($event.target as HTMLInputElement).value)" />
</template>

This replaces the .sync modifier from Vue 2. It is particularly useful for form components that manage multiple fields.

defineModel (Vue 3.4+)

Vue 3.4 introduced defineModel, which eliminates the boilerplate of defining the prop and emit separately:

<!-- RatingInput.vue with defineModel -->
<script setup lang="ts">
const rating = defineModel<number>({ required: true })
</script>

<template>
  <div class="rating">
    <button
      v-for="n in 5"
      :key="n"
      :class="{ active: n <= (rating ?? 0) }"
      @click="rating = n"
    >
      {{ n <= (rating ?? 0) ? 'X' : 'O' }}
    </button>
  </div>
</template>

defineModel returns a ref that you can read and write directly. It handles the prop and emit wiring behind the scenes. For named models:

<script setup lang="ts">
const firstName = defineModel<string>('firstName', { required: true })
const lastName = defineModel<string>('lastName', { required: true })
</script>

This is the recommended approach for new code. It is significantly less verbose.

Prop Immutability

Props are read-only. Mutating a prop directly triggers a warning in development:

// This is wrong:
props.title = 'New Title'  // Vue warns: "Attempting to mutate prop"

If you need local mutable state based on a prop, copy it:

<script setup lang="ts">
import { ref, watch } from 'vue'

const props = defineProps<{ initialCount: number }>()

// Copy prop to local state
const count = ref(props.initialCount)

// Optionally sync if parent can change it
watch(() => props.initialCount, (newVal) => {
  count.value = newVal
})
</script>

Or use computed if the derived value does not need to be independently mutable:

<script setup lang="ts">
import { computed } from 'vue'

const props = defineProps<{ items: string[] }>()

const sortedItems = computed(() => [...props.items].sort())
</script>

Boolean Casting

Vue has special handling for boolean props that matches HTML attribute behavior:

<script setup lang="ts">
defineProps<{
  disabled?: boolean
  visible?: boolean
}>()
</script>
<!-- All equivalent for boolean props -->
<MyButton disabled />
<MyButton :disabled="true" />
<MyButton disabled="" />

Just including the attribute name without a value sets it to true, matching how HTML attributes like disabled and readonly work.

Common Pitfalls

  • Mutating props directly: Props are read-only. Copy to a local ref if you need mutability, or emit an event to let the parent update.
  • Object/array defaults without factory functions: default: [] creates a shared array across all instances. Always use default: () => [].
  • Forgetting that defineProps and defineEmits are macros: They do not need to be imported. Importing them from vue will not cause errors, but it is unnecessary noise.
  • Using runtime and type-based props together: You cannot mix defineProps({ ... }) with defineProps<{ ... }>(). Pick one syntax.
  • Not using defineModel in new code: If you are on Vue 3.4+, defineModel eliminates significant boilerplate for two-way binding. There is no reason to manually wire modelValue + update:modelValue anymore.
  • Emitting events with wrong payload types: TypeScript catches this at compile time when you use typed defineEmits, but only if you actually define the types. Untyped emits give you no safety net.

Key Takeaways

  • Use TypeScript-only defineProps<{}>() for type-safe, concise prop definitions. Add withDefaults for default values.
  • defineEmits with tuple syntax provides typed event emission. The parent listens with @eventName.
  • v-model on components uses modelValue prop and update:modelValue event. Named v-models support multiple bindings.
  • defineModel (Vue 3.4+) is the modern shorthand for v-model support -- use it in new code.
  • Props are immutable. Copy to a local ref or use computed for derived values.
  • These are all compiler macros: no import needed, processed at build time, zero runtime overhead.