Files
2026-04-17 23:26:01 +00:00

6.8 KiB

title, impact, impactDescription, type, tags
title impact impactDescription type tags
Component Data Flow Best Practices HIGH Clear data flow between components prevents state bugs, stale UI, and brittle coupling best-practice
vue3
props
emits
v-model
provide-inject
data-flow
typescript

Component Data Flow Best Practices

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.

The 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.

Task List

  • Treat props as read-only inputs
  • Use props/emit for component communication; reserve refs for imperative actions
  • When refs are required for imperative APIs, type them with template refs
  • Emit events instead of mutating parent state directly
  • Use defineModel for v-model in modern Vue (3.4+)
  • Handle v-model modifiers deliberately in child components
  • Use symbols for provide/inject keys to avoid props drilling (over ~3 layers)
  • Keep mutations in the provider or expose explicit actions
  • In TypeScript projects, prefer type-based defineProps, defineEmits, and InjectionKey

Props: One-Way Data Down

Props are inputs. Do not mutate them in the child.

BAD:

<script setup>
const props = defineProps({ count: Number })

function increment() {
  props.count++
}
</script>

GOOD:

If state needs to change, emit an event, use v-model or create a local copy.

Prefer props/emit over component refs

BAD:

<script setup>
import { ref } from 'vue'
import UserForm from './UserForm.vue'

const formRef = ref(null)

function submitForm() {
  if (formRef.value.isValid) {
    formRef.value.submit()
  }
}
</script>

<template>
  <UserForm ref="formRef" />
  <button @click="submitForm">Submit</button>
</template>

GOOD:

<script setup>
import UserForm from './UserForm.vue'

function handleSubmit(formData) {
  api.submit(formData)
}
</script>

<template>
  <UserForm @submit="handleSubmit" />
</template>

Type component refs when imperative access is required

Prefer 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.

BAD:

<script setup lang="ts">
import { ref, onMounted } from 'vue'
import DialogPanel from './DialogPanel.vue'

const panelRef = ref(null)

onMounted(() => {
  panelRef.value.open()
})
</script>

<template>
  <DialogPanel ref="panelRef" />
</template>

GOOD:

<!-- DialogPanel.vue -->
<script setup lang="ts">
function open() {}

defineExpose({ open })
</script>
<!-- Parent.vue -->
<script setup lang="ts">
import { onMounted, useTemplateRef } from 'vue'
import DialogPanel from './DialogPanel.vue'

// Vue 3.5+ with useTemplateRef
const panelRef = useTemplateRef('panelRef')

// Before Vue 3.5 with manual typing and ref
// const panelRef = ref<InstanceType<typeof DialogPanel> | null>(null)

onMounted(() => {
  panelRef.value?.open()
})
</script>

<template>
  <DialogPanel ref="panelRef" />
</template>

Emits: Explicit Events Up

Component events do not bubble. If a parent needs to know about an event, re-emit it explicitly.

BAD:

<!-- Parent expects "saved" from grandchild, but it won't bubble -->
<Child @saved="onSaved" />

GOOD:

<!-- Child.vue -->
<script setup>
const emit = defineEmits(['saved'])

function onGrandchildSaved(payload) {
  emit('saved', payload)
}
</script>

<template>
  <Grandchild @saved="onGrandchildSaved" />
</template>

Event naming: use kebab-case in templates and camelCase in script:

<script setup>
const emit = defineEmits(['updateUser'])
</script>

<template>
  <ProfileForm @update-user="emit('updateUser', $event)" />
</template>

v-model: Predictable Two-Way Bindings

Use 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.

BAD:

<script setup>
const props = defineProps({ value: String })
</script>

<template>
  <input :value="props.value" @input="$emit('input', $event.target.value)" />
</template>

GOOD (Vue 3.4+):

<script setup>
const model = defineModel({ type: String })
</script>

<template>
  <input v-model="model" />
</template>

GOOD (Vue < 3.4):

<script setup>
const props = defineProps({ modelValue: String })
const emit = defineEmits(['update:modelValue'])
</script>

<template>
  <input
    :value="props.modelValue"
    @input="emit('update:modelValue', $event.target.value)"
  />
</template>

If you need the updated value immediately after a change, use the input event value or nextTick in the parent.

Provide/Inject: Shared Context Without Prop Drilling

Use provide/inject for cross-tree state, but keep mutations centralized in the provider and expose explicit actions.

BAD:

// Provider.vue
provide('theme', reactive({ dark: false }))

// Consumer.vue
const theme = inject('theme')
// Mutating shared state from any depth becomes hard to track
theme.dark = true

GOOD:

// Provider.vue
const theme = reactive({ dark: false })
const toggleTheme = () => { theme.dark = !theme.dark }

provide(themeKey, readonly(theme))
provide(themeActionsKey, { toggleTheme })

// Consumer.vue
const theme = inject(themeKey)
const { toggleTheme } = inject(themeActionsKey)

Use symbols for keys to avoid collisions in large apps:

export const themeKey = Symbol('theme')
export const themeActionsKey = Symbol('theme-actions')

Use TypeScript Contracts for Public Component APIs

In TypeScript projects, type component boundaries directly with defineProps, defineEmits, and InjectionKey so invalid payloads and mismatched injections fail at compile time.

BAD:

<script setup lang="ts">
import { inject } from 'vue'

const props = defineProps({
  userId: String
})

const emit = defineEmits(['save'])
const settings = inject('settings')

// Payload shape is not checked here
emit('save', 123)

// Key is string-based and not type-safe
settings?.theme = 'dark'
</script>

GOOD:

<script setup lang="ts">
import { inject, provide } from 'vue'
import type { InjectionKey } from 'vue'

interface Props {
  userId: string
}

interface Emits {
  save: [payload: { id: string; draft: boolean }]
}

interface Settings {
  theme: 'light' | 'dark'
}

const settingsKey: InjectionKey<Settings> = Symbol('settings')

const props = defineProps<Props>()
const emit = defineEmits<Emits>()

provide(settingsKey, { theme: 'light' })

const settings = inject(settingsKey)
if (settings) {
  emit('save', { id: props.userId, draft: false })
}
</script>