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

5.3 KiB

title, impact, impactDescription, type, tags
title impact impactDescription type tags
Prefer Props and Emit Over Component Refs MEDIUM Component refs create tight coupling and break component abstraction best-practice
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:

<!-- 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>
<!-- 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:

<!-- 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>
<!-- 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

<!-- 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>
<!-- 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