Files
agent-skills/skills/vue-best-practices/reference/prefer-props-emit-over-component-refs.md
Jason Woltje f5792c40be feat: Complete fleet — 94 skills across 10+ domains
Pulled ALL skills from 15 source repositories:
- anthropics/skills: 16 (docs, design, MCP, testing)
- obra/superpowers: 14 (TDD, debugging, agents, planning)
- coreyhaines31/marketingskills: 25 (marketing, CRO, SEO, growth)
- better-auth/skills: 5 (auth patterns)
- vercel-labs/agent-skills: 5 (React, design, Vercel)
- antfu/skills: 16 (Vue, Vite, Vitest, pnpm, Turborepo)
- Plus 13 individual skills from various repos

Mosaic Stack is not limited to coding — the Orchestrator and
subagents serve coding, business, design, marketing, writing,
logistics, analysis, and more.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 16:27:42 -06:00

217 lines
5.3 KiB
Markdown

---
title: Prefer Props and Emit Over Component Refs
impact: MEDIUM
impactDescription: Component refs create tight coupling and break component abstraction
type: best-practice
tags: [vue3, component-refs, props, emit, component-design, architecture]
---
# Prefer Props and Emit Over Component Refs
**Impact: MEDIUM** - Using template refs to access child component internals creates tight coupling between parent and child. This makes components harder to maintain, refactor, and reuse. Props and emit provide a cleaner contract-based API that preserves component encapsulation.
Component refs should be reserved for imperative actions (focus, scroll, animations) that can't be expressed declaratively.
## Task Checklist
- [ ] Use props for passing data down to children
- [ ] Use emit for communicating events up to parents
- [ ] Only use component refs for imperative DOM operations
- [ ] If using refs, expose a minimal, documented API via defineExpose
- [ ] Consider if the interaction can be expressed declaratively
**Incorrect:**
```vue
<!-- ParentComponent.vue -->
<script setup>
import { ref, onMounted } from 'vue'
import UserForm from './UserForm.vue'
const formRef = ref(null)
// WRONG: Reaching into child's internals
function submitForm() {
// Tight coupling - parent knows child's internal structure
if (formRef.value.isValid) {
const data = formRef.value.formData
formRef.value.setSubmitting(true)
api.submit(data).then(() => {
formRef.value.setSubmitting(false)
formRef.value.reset()
})
}
}
// WRONG: Parent managing child's state
function prefillForm(userData) {
formRef.value.formData.name = userData.name
formRef.value.formData.email = userData.email
}
</script>
<template>
<UserForm ref="formRef" />
<button @click="submitForm">Submit</button>
</template>
```
```vue
<!-- UserForm.vue - exposing too much -->
<script setup>
import { ref, reactive } from 'vue'
const formData = reactive({ name: '', email: '' })
const isValid = ref(false)
const isSubmitting = ref(false)
function setSubmitting(value) {
isSubmitting.value = value
}
function reset() {
formData.name = ''
formData.email = ''
}
// WRONG: Exposing internal state details
defineExpose({
formData,
isValid,
isSubmitting,
setSubmitting,
reset
})
</script>
```
**Correct:**
```vue
<!-- ParentComponent.vue -->
<script setup>
import { ref } from 'vue'
import UserForm from './UserForm.vue'
const initialData = ref({ name: '', email: '' })
const isSubmitting = ref(false)
// CORRECT: Child communicates via events
function handleSubmit(formData) {
isSubmitting.value = true
api.submit(formData).finally(() => {
isSubmitting.value = false
})
}
function handleValidChange(isValid) {
console.log('Form validity:', isValid)
}
</script>
<template>
<!-- CORRECT: Props down, events up -->
<UserForm
:initial-data="initialData"
:submitting="isSubmitting"
@submit="handleSubmit"
@valid-change="handleValidChange"
/>
</template>
```
```vue
<!-- UserForm.vue - clean props/emit interface -->
<script setup>
import { reactive, computed, watch } from 'vue'
const props = defineProps({
initialData: { type: Object, default: () => ({}) },
submitting: { type: Boolean, default: false }
})
const emit = defineEmits(['submit', 'valid-change'])
const formData = reactive({ ...props.initialData })
const isValid = computed(() => {
return formData.name.length > 0 && formData.email.includes('@')
})
watch(isValid, (valid) => {
emit('valid-change', valid)
})
function handleSubmit() {
if (isValid.value) {
emit('submit', { ...formData })
}
}
</script>
<template>
<form @submit.prevent="handleSubmit">
<input v-model="formData.name" :disabled="submitting" />
<input v-model="formData.email" :disabled="submitting" />
<button type="submit" :disabled="!isValid || submitting">
{{ submitting ? 'Submitting...' : 'Submit' }}
</button>
</form>
</template>
```
## When Component Refs ARE Appropriate
```vue
<!-- CORRECT: Refs for imperative DOM operations -->
<script setup>
import { ref } from 'vue'
import CustomInput from './CustomInput.vue'
const inputRef = ref(null)
// Imperative focus action - good use of refs
function focusInput() {
inputRef.value?.focus()
}
</script>
<template>
<CustomInput ref="inputRef" v-model="text" />
<button @click="focusInput">Focus Input</button>
</template>
```
```vue
<!-- CustomInput.vue - minimal imperative API -->
<script setup>
import { ref } from 'vue'
const inputEl = ref(null)
// Only expose imperative methods
defineExpose({
focus: () => inputEl.value?.focus(),
blur: () => inputEl.value?.blur(),
select: () => inputEl.value?.select()
})
</script>
<template>
<input ref="inputEl" v-bind="$attrs" />
</template>
```
## Summary
| Use Case | Approach |
|----------|----------|
| Pass data to child | Props |
| Child notifies parent | Emit events |
| Two-way binding | v-model (props + emit) |
| Focus, scroll, animate | Component ref with minimal expose |
| Access child internal state | Refactor to use props/emit |
## Reference
- [Vue.js Component Basics - Props](https://vuejs.org/guide/components/props.html)
- [Vue.js Component Events](https://vuejs.org/guide/components/events.html)
- [Vue.js Template Refs - Ref on Component](https://vuejs.org/guide/essentials/template-refs.html#ref-on-component)