06 — Component Communication
Props Down, Events Up — The Unidirectional Contract
Parent.vue
<script setup lang="ts">
import { ref } from 'vue'
import UserForm from './UserForm.vue'
const user = ref({ name: '', email: '' })
// ── One-way data flow: ────────────────────────────────
// Parent → Child: props (read-only in child)
// Child → Parent: events (child emits, parent handles)
// Child never mutates props directly — emits event, parent updates state.
// This ensures a single source of truth and traceable data flow.
</script>
<template>
<!-- Pass data down as props -->
<UserForm
:model-value="user"
@update:model-value="user = $event"
@submit="handleSubmit"
/>
<!-- v-model is sugar for the above two-way binding pattern -->
</template>
UserForm.vue
<script setup lang="ts">
// ── Props are READ-ONLY — mutating a prop is a dev-mode warning ──
// Vue's one-way flow: if parent changes the prop, child re-renders with new value.
// If child mutated it, the next parent update would overwrite the mutation.
const props = defineProps<{
modelValue: { name: string; email: string }
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: { name: string; email: string }): void
(e: 'submit'): void
}>()
// ── Correct pattern: emit a new object, don't mutate the prop ──
function updateField(field: 'name' | 'email', value: string) {
emit('update:modelValue', { ...props.modelValue, [field]: value })
}
</script>
<template>
<input
:value="modelValue.name"
@input="updateField('name', $event.target.value)"
/>
<button @click="emit('submit')">Submit</button>
</template>
Anti-Pattern: Mutating Props Directly
MutateProp.vue
<script setup>
const props = defineProps(['user'])
</script>
<template>
<!-- ❌ WRONG: mutating a prop directly — Vue warns in dev mode -->
<input v-model="props.user.name" />
<!-- Why it breaks: parent owns `user`. If parent re-renders with a new
user object, this component's mutation is lost (overwritten by new prop).
Also makes data flow untraceable — who changed `user.name`? -->
</template>
FixedProp.vue
<script setup>
const props = defineProps(['user'])
const emit = defineEmits(['update:user'])
</script>
<template>
<!-- ✅ CORRECT: emit the update, parent owns the mutation -->
<input
:value="user.name"
@input="emit('update:user', { ...user, name: $event.target.value })"
/>
</template>
provide/inject — Typed Dependency Injection
provide-inject.ts
import { provide, inject, ref, type InjectionKey, type Ref } from 'vue'
// ── InjectionKey: typed symbol that carries the TypeScript type ──
// Using a symbol (not a string) prevents naming collisions across libraries.
// The type is: Ref<User | null> — injected values are typed correctly.
export const USER_KEY: InjectionKey<Ref<User | null>> = Symbol('user')
// ── Provider: parent or app-level ──
export function provideUser() {
const user = ref<User | null>(null)
provide(USER_KEY, user) // provide the ref — child gets the ref, not just the value
return user
}
// ── Consumer: any descendant component ──
export function useUser() {
const user = inject(USER_KEY)
if (!user) throw new Error('useUser() must be used within a provider for USER_KEY')
// ⚠️ inject() returns undefined if no provider found — always guard.
return user
}
// ── Without InjectionKey: string key, no type safety ──
provide('theme', 'dark')
const theme = inject('theme', 'light') // second arg = default if not provided
// String keys work but have no type inference — InjectionKey is preferred.
Production Pattern — Composable with provide/inject
useTheme.ts
import { provide, inject, ref, readonly, computed, type InjectionKey } from 'vue'
// ── Full composable: provider sets up state, consumer accesses via inject ──
// Separates "create" (provider) from "use" (consumer) for clean DI.
interface ThemeContext {
theme: Readonly<Ref<string>>
isDark: Readonly<Ref<boolean>>
setTheme: (t: string) => void
toggle: () => void
}
const THEME_KEY: InjectionKey<ThemeContext> = Symbol('theme')
// ── Provider: called once in the root component ──
export function provideTheme(initial = 'light') {
const theme = ref(initial)
const isDark = computed(() => theme.value === 'dark')
const context: ThemeContext = {
theme: readonly(theme), // expose read-only — consumers can't mutate directly
isDark: readonly(isDark),
setTheme: (t: string) => { theme.value = t },
toggle: () => { theme.value = theme.value === 'dark' ? 'light' : 'dark' },
}
provide(THEME_KEY, context)
return context // provider also gets the mutable API
}
// ── Consumer: called in any descendant ──
export function useTheme() {
const ctx = inject(THEME_KEY)
if (!ctx) throw new Error('useTheme() called outside of provideTheme()')
return ctx
}
// ── Why readonly on injected state: ──────────────────────
// Forces consumers to use the provided mutation functions (setTheme, toggle)
// instead of directly writing to the ref. Centralizes mutation logic, makes
// state changes traceable and debuggable (all mutations go through one path).
v-model on Components — Custom Modifiers
PhoneNumberInput.vue
<script setup>
import { computed } from 'vue'
// ── v-model with built-in modifiers: modelModifiers ──
// Parent: <PhoneInput v-model.trim="phone" />
// → modelModifiers = { trim: true }
const props = defineProps({
modelValue: String,
modelModifiers: { default: () => ({}) },
})
const emit = defineEmits(['update:modelValue'])
// ── Custom modifier: format phone numbers as (XXX) XXX-XXXX ──
function formatPhone(value) {
const digits = value.replace(/\D/g, '').slice(0, 10)
if (digits.length <= 3) return digits
if (digits.length <= 6) return `(${digits.slice(0, 3)}) ${digits.slice(3)}`
return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`
}
function onInput(e) {
let value = e.target.value
// Apply custom modifier if present
if (props.modelModifiers.format) {
value = formatPhone(value)
}
emit('update:modelValue', value)
}
</script>
<template>
<input :value="modelValue" @input="onInput" />
</template>
Multiple v-model with Named Bindings
DateRangePicker.vue
<script setup>
// ── Named v-models: v-model:startDate and v-model:endDate ──
// Parent: <DateRange v-model:start="start" v-model:end="end" />
// Each named v-model gets its own prop + modifier prop:
// start, startModifiers, end, endModifiers
defineProps({
start: String,
end: String,
})
const emit = defineEmits(['update:start', 'update:end'])
</script>
<template>
<input type="date" :value="start" @input="emit('update:start', $event.target.value)" />
<input type="date" :value="end" @input="emit('update:end', $event.target.value)" />
</template>
Event Payload Validation and v-model on Custom Components
ValidatedInput.vue
<script setup>
import { ref, computed } from 'vue'
const props = defineProps({
modelValue: String,
rules: { type: Array, default: () => [] }, // validation rules array
})
const emit = defineEmits(['update:modelValue', 'validate'])
const error = ref('')
// ── Computed validation — re-runs when modelValue or rules change ──
const isValid = computed(() => {
for (const rule of props.rules) {
const result = rule(props.modelValue)
if (typeof result === 'string') {
error.value = result
return false
}
}
error.value = ''
return true
})
function onInput(e) {
const value = e.target.value
emit('update:modelValue', value)
// Emit validation result after the value update — parent can react
emit('validate', isValid.value)
}
</script>
<template>
<input :value="modelValue" @input="onInput" />
<span v-if="error" class="error">{{ error }}</span>
</template>
💡 Tips & Tricks
tips.ts
// ── 1. provide/inject with factory default ──
const config = inject(CONFIG_KEY, () => createDefaultConfig(), true)
// 3rd arg true: treat the 2nd arg as a factory function (like prop defaults)
// ── 2. Reactive provide — pass the ref, not the value ──
// ✅ provide(KEY, myRef) — child injects the ref, stays reactive
// ❌ provide(KEY, myRef.value) — child gets a static value, no reactivity
// ── 3. Event naming: kebab-case in template, camelCase in emit ──
// emit('update:modelValue') → parent listens: @update:model-value
// Vue auto-converts camelCase emits to kebab-case in templates.
// ── 4. useAttrs() — access fallthrough attrs in script ──
const attrs = useAttrs()
// Equivalent to $attrs in template, but accessible in <script setup>.
// Useful for forwarding attrs to specific children in wrapper components.
⚠️ Edge Cases & Gotchas
edge-cases.ts
// ── 1. Injecting a non-provided key returns undefined, not an error ──
const user = inject(USER_KEY) // undefined if no provider — always guard
if (!user) throw new Error('USER_KEY not provided')
// Or use a default: inject(USER_KEY, ref(null))
// ── 2. provide/inject is NOT reactive across component boundaries ──
// If you provide a plain value (not a ref), changes to it in the provider
// do NOT update consumers. Always provide refs or reactive objects.
// ── 3. Prop mutation in child — silent failure in production ──
// Dev mode: Vue warns "Avoid mutating prop directly."
// Prod mode: mutation works... until parent re-renders and overwrites it.
// This causes "works in dev, breaks in prod" bugs.
// ── 4. Event names must match exactly (case-sensitive in emit) ──
// emit('update:modelValue') + parent @update:model-value → ✅ (auto kebab)
// emit('updateModelValue') + parent @update-model-value → ❌ (no match)
// ── 5. provide/inject is hierarchical — not broadcast ──
// Only DESCENDANT components can inject. Siblings cannot.
// For cross-tree communication, use a Pinia store or event bus pattern.
// ── 6. v-model modifiers prop is always an object ──
// modelModifiers default is () => ({}) — never undefined.
// Check with: if (props.modelModifiers.trim) { ... }
🧠 Spot the Bug
A child component mutates a prop object's nested property, but the parent's state doesn't update.
PropMutationBug.vue
<script setup>
const props = defineProps({ user: Object })
</script>
<template>
<!-- Mutating a nested property of a prop object -->
<input v-model="props.user.name" />
</template>
Answer
Mutating a prop object's nested property appears to work (the object is passed by reference), but it violates Vue's one-way data flow. The parent doesn't know the object changed, so it won't trigger any watchers or re-renders in the parent. If the parent later replaces the user object (e.g., after a fetch), the child's mutation is silently lost.
Fix — emit the update, parent owns the mutation:
PropMutationFixed.vue
<script setup>
const props = defineProps({ user: Object })
const emit = defineEmits(['update:user'])
</script>
<template>
<input
:value="user.name"
@input="emit('update:user', { ...user, name: $event.target.value })"
/>
</template>
The lesson: props are read-only contracts. Even nested mutations work at the JS level but break Vue's reactivity tracking. Always emit updates — the parent is the single source of truth.