Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
Enforces Vue 3 Composition API best practices with script setup, TypeScript, Pinia, and Vite.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
references/component-data-flow.md
1---2title: Component Data Flow Best Practices3impact: HIGH4impactDescription: Clear data flow between components prevents state bugs, stale UI, and brittle coupling5type: best-practice6tags: [vue3, props, emits, v-model, provide-inject, data-flow, typescript]7---89# Component Data Flow Best Practices1011**Impact: HIGH** - Vue components stay reliable when data flow is explicit: props go down, events go up, `v-model` handles two-way bindings, and provide/inject supports cross-tree dependencies. Blurring these boundaries leads to stale state, hidden coupling, and hard-to-debug UI.1213The main principle of data flow in Vue.js is **Props Down / Events Up**. This is the most maintainable default, and one-way flow scales well.1415## Task List1617- Treat props as read-only inputs18- Use props/emit for component communication; reserve refs for imperative actions19- When refs are required for imperative APIs, type them with template refs20- Emit events instead of mutating parent state directly21- Use `defineModel` for v-model in modern Vue (3.4+)22- Handle v-model modifiers deliberately in child components23- Use symbols for provide/inject keys to avoid props drilling (over ~3 layers)24- Keep mutations in the provider or expose explicit actions25- In TypeScript projects, prefer type-based `defineProps`, `defineEmits`, and `InjectionKey`2627## Props: One-Way Data Down2829Props are inputs. Do not mutate them in the child.3031**BAD:**32```vue33<script setup>34const props = defineProps({ count: Number })3536function increment() {37props.count++38}39</script>40```4142**GOOD:**4344If state needs to change, emit an event, use `v-model` or create a local copy.4546## Prefer props/emit over component refs4748**BAD:**49```vue50<script setup>51import { ref } from 'vue'52import UserForm from './UserForm.vue'5354const formRef = ref(null)5556function submitForm() {57if (formRef.value.isValid) {58formRef.value.submit()59}60}61</script>6263<template>64<UserForm ref="formRef" />65<button @click="submitForm">Submit</button>66</template>67```6869**GOOD:**70```vue71<script setup>72import UserForm from './UserForm.vue'7374function handleSubmit(formData) {75api.submit(formData)76}77</script>7879<template>80<UserForm @submit="handleSubmit" />81</template>82```8384## Type component refs when imperative access is required8586Prefer props/emits by default. When a parent must call an exposed child method, type the ref explicitly and expose only the intended API from the child with `defineExpose`.8788**BAD:**89```vue90<script setup lang="ts">91import { ref, onMounted } from 'vue'92import DialogPanel from './DialogPanel.vue'9394const panelRef = ref(null)9596onMounted(() => {97panelRef.value.open()98})99</script>100101<template>102<DialogPanel ref="panelRef" />103</template>104```105106**GOOD:**107```vue108<!-- DialogPanel.vue -->109<script setup lang="ts">110function open() {}111112defineExpose({ open })113</script>114```115116```vue117<!-- Parent.vue -->118<script setup lang="ts">119import { onMounted, useTemplateRef } from 'vue'120import DialogPanel from './DialogPanel.vue'121122// Vue 3.5+ with useTemplateRef123const panelRef = useTemplateRef('panelRef')124125// Before Vue 3.5 with manual typing and ref126// const panelRef = ref<InstanceType<typeof DialogPanel> | null>(null)127128onMounted(() => {129panelRef.value?.open()130})131</script>132133<template>134<DialogPanel ref="panelRef" />135</template>136```137138## Emits: Explicit Events Up139140Component events do not bubble. If a parent needs to know about an event, re-emit it explicitly.141142**BAD:**143```vue144<!-- Parent expects "saved" from grandchild, but it won't bubble -->145<Child @saved="onSaved" />146```147148**GOOD:**149```vue150<!-- Child.vue -->151<script setup>152const emit = defineEmits(['saved'])153154function onGrandchildSaved(payload) {155emit('saved', payload)156}157</script>158159<template>160<Grandchild @saved="onGrandchildSaved" />161</template>162```163164**Event naming:** use kebab-case in templates and camelCase in script:165```vue166<script setup>167const emit = defineEmits(['updateUser'])168</script>169170<template>171<ProfileForm @update-user="emit('updateUser', $event)" />172</template>173```174175## `v-model`: Predictable Two-Way Bindings176177Use `defineModel` by default for component bindings and emit updates on input. Only use the `modelValue` + `update:modelValue` pattern if you are on Vue < 3.4.178179**BAD:**180```vue181<script setup>182const props = defineProps({ value: String })183</script>184185<template>186<input :value="props.value" @input="$emit('input', $event.target.value)" />187</template>188```189190**GOOD (Vue 3.4+):**191```vue192<script setup>193const model = defineModel({ type: String })194</script>195196<template>197<input v-model="model" />198</template>199```200201**GOOD (Vue < 3.4):**202```vue203<script setup>204const props = defineProps({ modelValue: String })205const emit = defineEmits(['update:modelValue'])206</script>207208<template>209<input210:value="props.modelValue"211@input="emit('update:modelValue', $event.target.value)"212/>213</template>214```215216If you need the updated value immediately after a change, use the input event value or `nextTick` in the parent.217218## Provide/Inject: Shared Context Without Prop Drilling219220Use provide/inject for cross-tree state, but keep mutations centralized in the provider and expose explicit actions.221222**BAD:**223```vue224// Provider.vue225provide('theme', reactive({ dark: false }))226227// Consumer.vue228const theme = inject('theme')229// Mutating shared state from any depth becomes hard to track230theme.dark = true231```232233**GOOD:**234```vue235// Provider.vue236const theme = reactive({ dark: false })237const toggleTheme = () => { theme.dark = !theme.dark }238239provide(themeKey, readonly(theme))240provide(themeActionsKey, { toggleTheme })241242// Consumer.vue243const theme = inject(themeKey)244const { toggleTheme } = inject(themeActionsKey)245```246247Use symbols for keys to avoid collisions in large apps:248```ts249export const themeKey = Symbol('theme')250export const themeActionsKey = Symbol('theme-actions')251```252253## Use TypeScript Contracts for Public Component APIs254255In TypeScript projects, type component boundaries directly with `defineProps`, `defineEmits`, and `InjectionKey` so invalid payloads and mismatched injections fail at compile time.256257**BAD:**258```vue259<script setup lang="ts">260import { inject } from 'vue'261262const props = defineProps({263userId: String264})265266const emit = defineEmits(['save'])267const settings = inject('settings')268269// Payload shape is not checked here270emit('save', 123)271272// Key is string-based and not type-safe273settings?.theme = 'dark'274</script>275```276277**GOOD:**278```vue279<script setup lang="ts">280import { inject, provide } from 'vue'281import type { InjectionKey } from 'vue'282283interface Props {284userId: string285}286287interface Emits {288save: [payload: { id: string; draft: boolean }]289}290291interface Settings {292theme: 'light' | 'dark'293}294295const settingsKey: InjectionKey<Settings> = Symbol('settings')296297const props = defineProps<Props>()298const emit = defineEmits<Emits>()299300provide(settingsKey, { theme: 'light' })301302const settings = inject(settingsKey)303if (settings) {304emit('save', { id: props.userId, draft: false })305}306</script>307```308