5.5 KiB
5.5 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 |
|
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 |