Priority A Rules: Essential (TypeScript)
These rules define the most important boundaries in Vue components that use TypeScript: what a component exposes, how data flows through it, how its styles are contained, and how derived state is kept separate from side effects. Follow them by default to keep components easier to understand, maintain, and evolve.
Component Data Flow
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.
Props Declaration
In committed code, props should be treated as a key part of the component's contract and be defined in as much detail as possible.
Why this matters
Typed prop definitions make a component's API easier to understand, help editors and vue-tsc catch incorrect usage, and make refactors safer.
Bad
ts
defineProps(['status'])Good
ts
defineProps<{
status: string
}>()Access props variables
In Vue 3.5+, you can destructure typed props directly from defineProps() and keeps them reactive.
Under the hood, Vue's compiler automatically prepends
props.when code in the samescript setupblock accesses variables destructured fromdefineProps(), see Reactive Props Destructure for details.
In Vue's VS Code extension, you can enable
vue.inlayHints.destructuredPropsoption to show inlay-hints for destructured props.
ts
const { status } = defineProps<{
status: string
}>()
watchEffect(() => {
console.log(status)
})In Vue 3.4 and earlier, assign the result of defineProps() to a variable and access props through that variable.
ts
const props = defineProps<{
status: string
}>()
watchEffect(() => {
console.log(props.status)
})Declare prop defaults
In Vue 3.5+, you can use JavaScript's native default value syntax to declare default values for the props.
ts
const { status = 'syncing' } = defineProps<{
status?: string
}>()In Vue 3.4 and earlier, use withDefaults() to declare default values for props.
ts
const props = withDefaults(defineProps<{
status?: string
}>(), {
status: 'syncing'
})Emits Declaration
Treat emitted events as part of a component's public contract, and declare event names and payloads explicitly.
Why this matters
Typed event declarations document how a component communicates outward and let TypeScript catch wrong event names or payload shapes before runtime.
Use defineEmits() with named tuple syntax when events have payloads. Use a payload object when an event carries more than one value or may grow over time.
Bad
vue
<template>
<button @click="$emit('submit')">Submit</button>
</template>Good
In Vue 3.3+, you can use named tuple syntax for a more succinct declaration:
vue
<script setup lang="ts">
const emit = defineEmits<{
submit: [payload: { email: string }]
close: []
}>()
</script>
<template>
<button type="button" @click="emit('submit', { email: 'john@example.com' })">Submit</button>
<button type="button" @click="emit('close')">Close</button>
</template>In Vue 3.2 and earlier, use the function syntax to declare the payload shape:
vue
<script setup lang="ts">
const emit = defineEmits<{
(event: 'submit', payload: { email: string }): void
(event: 'close'): void
}>()
</script>
<template>
<button type="button" @click="emit('submit', { email: 'john@example.com' })">Submit</button>
<button type="button" @click="emit('close')">Close</button>
</template>Implement two-way binding
Pass data down with props, and communicate requested changes back up with emitted events or typed defineModel().
Why this matters
Vue components are easier to understand and maintain when state ownership is clear. Prop mutation and other implicit parent-child coupling make updates harder to reason about and components harder to reuse.
This includes v-model, which still follows the same prop-and-event communication pattern with shorthand syntax.
If a child needs editable local state, derive or initialize it from the prop instead of mutating the prop itself.
Bad
vue
<script setup lang="ts">
const props = defineProps<{
open: boolean
}>()
function requestClose() {
props.open = false
}
</script>Good
In Vue 3.4+, use defineModel() for two-way bindings.
vue
<script setup lang="ts">
const open = defineModel<boolean>()
function requestClose() {
open.value = false
}
</script>In Vue 3.3 and earlier, use defineEmits() to request changes from the parent.
vue
<script setup lang="ts">
defineProps<{
modelValue: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
function requestClose() {
emit('update:modelValue', false)
}
</script>Usage in parent component:
vue
<script setup lang="ts">
const isOpened = ref(false)
</script>
<template>
<Child v-model="isOpened" />
</template>Single-File Component
Using SFCs with consistent structure and performant styling keeps components easier to maintain and avoids unnecessary render overhead.
Access DOM or component instances
Bad
vue
<script setup>
import { ref, onMounted } from 'vue'
const el = ref()
onMounted(() => {
el.value = window.document.getElementById('my-input')
el.value.focus()
})
</script>
<template>
<input id="my-input" />
</template>Good
In Vue 3.5+, use useTemplateRef() to access DOM or component instances.
vue
<script setup lang="ts">
import { useTemplateRef, onMounted } from 'vue'
const el = useTemplateRef('my-input')
onMounted(() => {
if (el.value) {
el.value.focus()
}
})
</script>
<template>
<input ref="my-input" />
</template>In Vue 3.4 and earlier, use ref and template refs to access DOM or component instances.
vue
<script setup lang="ts">
import { ref, onMounted } from 'vue'
const el = ref<HTMLInputElement | null>(null)
onMounted(() => {
if (el.value) {
el.value.focus()
}
})
</script>
<template>
<input ref="el" />
</template>Use component-scoped styling
Keep component styles within the component boundary unless a style is intentionally global.
Why this matters
Component-scoped styling reduces accidental coupling, makes style ownership clearer, and lowers the chance that a change in one component will unexpectedly affect another.
Bad
vue
<template>
<button type="button" class="btn btn-close">×</button>
</template>
<style>
.btn-close {
background-color: red;
}
</style>Good
vue
<template>
<button type="button" class="button button-close">×</button>
</template>
<!-- Using the `scoped` attribute -->
<style scoped>
.button {
border: none;
border-radius: 2px;
}
.button-close {
background-color: red;
}
</style>vue
<template>
<button type="button" :class="[$style.button, $style.buttonClose]">×</button>
</template>
<!-- Using CSS modules -->
<style module>
.button {
border: none;
border-radius: 2px;
}
.buttonClose {
background-color: red;
}
</style>Notes
- The
scopedattribute is not the only option. CSS modules, native CSS@scope, and disciplined class-based conventions such as BEM can all work. - App-level and layout-level styles may be global when they are intentionally shared.
Reactivity
Avoid destructuring from reactive() directly
Bad
vue
<script setup lang="ts">
import { onMounted, reactive } from 'vue'
const state = reactive({ count: 0 })
onMounted(() => {
state.count = 1
})
// disconnected from reactivity
const { count } = state
</script>
<template>
<!-- it's not updating to 1 on mount -->
<div>{{ count }}</div>
</template>Good
Read and write to properties on the original reactive() object to stay within the reactivity system.
vue
<script setup lang="ts">
import { onMounted, reactive } from 'vue'
const state = reactive({ count: 0 })
onMounted(() => {
state.count = 1
})
</script>
<template>
<div>{{ state.count }}</div>
</template>You can use toRef or toRefs() for a more destructure-like syntax that stays reactive.
vue
<script setup lang="ts">
import { onMounted, reactive, toRef } from 'vue'
const state = reactive({ count: 0 })
// a two-way ref that syncs with the original property
const count = toRef(state, 'count')
onMounted(() => {
count.value = 1
})
</script>
<template>
<div>{{ count }}</div>
</template>ts
// or create readonly ref from reactive with a getter
const count = toRef(() => state.count)Use toRefs() if you need to destructure multiple properties.
vue
<script setup lang="ts">
import { onMounted, reactive, toRefs } from 'vue'
const state = reactive({ count: 0 })
const { count } = toRefs(state)
onMounted(() => {
count.value = 2
})
</script>
<template>
<div>{{ count }}</div>
</template>Watching reactive state
When watching reactive state, use a getter function or ref instead of watching the property of reactive object directly.
Bad
ts
import { watch, reactive } from 'vue'
const props = defineProps<{
title: string
}>()
const state = reactive({ count: 0 })
// it won't trigger when props.title or state.count changes
watch(props.title, () => {})
watch(state.count, () => {})Good
ts
import { watch, reactive } from 'vue'
const props = defineProps<{
title: string
}>()
const state = reactive({ count: 0 })
// watch reactive state with getter
watch(() => props.title, () => {})
watch(() => state.count, () => {})Use computed for derived state
Use computed for synchronous derived state instead of storing and synchronizing it manually.
Keep computed getters pure (no side effects) and put side effects in watchers.
Why this matters
Computed state should describe what values mean, not perform work. Keeping derivation pure and synchronous makes reactive code easier to reason about and keeps side effects in the places Vue expects them.
Let computed refs infer their types when the implementation is obvious. Add an explicit return type when the computed value is part of a component contract, when it guards against accidental widening, or when inference becomes circular.
Bad
ts
const fullName = computed(() => {
const value = `${user.value.firstName} ${user.value.lastName}`
analytics.track('full-name-changed', value)
return value
})Good
ts
const fullName = computed(() => {
return `${user.value.firstName} ${user.value.lastName}`
})
watch(fullName, (value) => {
analytics.track('full-name-changed', value)
})Prefer computed over watcher-assigned derived refs
When a ref's value is derived from other reactive state, use a computed ref instead of a manually assigned ref in a watcher.
Bad
ts
const items = ref([{ price: 10 }, { price: 20 }])
const total = ref(0)
watch(items, () => {
total.value = items.value.reduce((sum, item) => sum + item.price, 0)
})Good
ts
const items = ref([{ price: 10 }, { price: 20 }])
const total = computed(() =>
items.value.reduce((sum, item) => sum + item.price, 0)
)Use eager watchers instead of duplicate initial calls
Use watchers with the immediate: true option instead of manually duplicating an initial call to a function that also needs to run on reactive updates.
Bad
ts
const userId = ref(1)
onMounted(() => loadUser(userId.value))
watch(userId, (id) => loadUser(id))Good
ts
const userId = ref(1)
watch(userId, (id) => loadUser(id), { immediate: true })