TS-42: Vue
This technical standard covers best practices for working with Vue, a progressive JavaScript framework for building user interfaces. The guidance here assumes Vue 3, authored with the Composition API and single-file components, which is the current recommended way to write Vue. For guidance on building interfaces with React instead, see TS-41: React.
Single-file components
Vue components SHOULD be authored as single-file components (.vue files), each combining a <template>, a
<script>, and an optional <style> block in one file. This keeps a component’s markup, behavior, and styling
colocated and reviewable as a single unit, rather than split across parallel directory structures.
<script setup>
import { ref } from "vue"
const count = ref(0)
</script>
<template>
<button @click="count++">Count: {{ count }}</button>
</template>
<style scoped>
button {
padding: 0.5rem 1rem;
}
</style>Block order within a single-file component MUST be <template>, then <script>, then <style>. This is the order
Vue’s own tooling (create-vue, the official VS Code extension) generates and expects, and keeps the file’s
structure predictable across a codebase.
Composition API and <script setup>
New components MUST use the Composition API with <script setup>, not the Options API. <script setup> is more
concise, gives better TypeScript inference, and avoids the this-binding ambiguity that the Options API carries
over from Vue 2. The Options API MAY still appear in a codebase migrated from Vue 2, but MUST NOT be used for new
components.
<!-- No: Options API -->
<script>
export default {
data() {
return { count: 0 }
},
methods: {
increment() {
this.count++
},
},
}
</script>
<!-- Yes: Composition API with <script setup> -->
<script setup>
import { ref } from "vue"
const count = ref(0)
function increment() {
count.value++
}
</script>TypeScript
Single-file components SHOULD use <script setup lang="ts">. Props and emits SHOULD be declared with the type-only
defineProps<T>() and defineEmits<T>() macros rather than the runtime declaration form, since the type-only form
is checked at compile time and produces no runtime overhead.
<script setup lang="ts">
interface Props {
label: string
disabled?: boolean
}
const props = defineProps<Props>()
const emit = defineEmits<{
submit: [value: string]
}>()
</script>Reactivity
ref versus reactive
ref() SHOULD be the default choice for reactive state, including objects and arrays. reactive() MAY be used for
a fixed-shape object that is never reassigned as a whole, but it has three sharp edges that ref() avoids:
- No reassignment. A
reactive()object loses reactivity if it is destructured or reassigned wholesale (state = { …newState }breaks the binding).ref()is reassigned by replacing.value, which always keeps reactivity intact. - No primitives.
reactive()only works on objects, arrays, and collection types (Map,Set).ref()works uniformly for primitives and objects, so a codebase that standardizes onref()does not need two mental models. - Destructuring loses reactivity. Pulling a property off a
reactive()object into a local variable (const { count } = state) yields a plain, non-reactive value.toRefs()works around this, but is an extra step thatref()-based state does not need.
import { ref, reactive } from "vue"
/* RECOMMENDED: uniform, safe to reassign or destructure via toRef/toRefs. */
const user = ref({ name: "Ada", roles: ["admin"] })
user.value.name = "Ada Lovelace"
/* Acceptable, but only if `settings` is never reassigned as a whole and its
properties are always accessed through the object, never destructured. */
const settings = reactive({ theme: "dark", locale: "en-US" })Computed properties over methods
A derived value that depends only on reactive state MUST be a computed(), not a method called from the template.
computed() caches its result and only re-evaluates when a dependency changes; a method re-runs on every render.
import { computed, ref } from "vue"
const items = ref([])
/* No: re-runs on every re-render, regardless of whether `items` changed. */
function total() {
return items.value.reduce((sum, item) => sum + item.price, 0)
}
/* Yes: cached, and only recomputed when `items` changes. */
const total = computed(() =>
items.value.reduce((sum, item) => sum + item.price, 0),
)A computed() getter MUST be a pure function of its reactive dependencies — it MUST NOT mutate state or perform
side effects. Use a watch() or watchEffect() for side effects instead.
watch versus watchEffect
watch() SHOULD be used when the source being observed needs to be explicit and the callback needs the previous
value, the old and new values, or lazy (non-immediate) evaluation. watchEffect() SHOULD be used when the
callback’s own body already reads every reactive dependency it needs — it tracks its dependencies automatically and
runs immediately.
import { ref, watch, watchEffect } from "vue"
const searchQuery = ref("")
/* Explicit source, needs the previous value: use watch(). */
watch(searchQuery, (newQuery, oldQuery) => {
console.log(`Query changed from "${oldQuery}" to "${newQuery}"`)
})
/* Dependencies are inferred from what the callback reads: use watchEffect(). */
watchEffect(() => {
document.title = `Results for "${searchQuery.value}"`
})Prefer a computed() over either watch() or watchEffect() where the goal is a derived value rather than a side
effect. A watch/watchEffect callback that only assigns its result to another ref SHOULD be replaced with a
computed().
Composables over mixins
Shared, stateful logic MUST be extracted into a composable — a function prefixed use that encapsulates
ref/computed/watch state and returns what the consuming component needs — not a Vue 2-style mixin. Mixins
have two problems composables do not: unclear property origin (a property used in a component’s template could
come from the component itself or from any of its mixins, with no way to tell without reading every mixin) and
implicit namespace collision (two mixins that happen to define the same property name silently overwrite one
another). A composable’s return value is destructured explicitly at the call site, so its origin is always visible.
// composables/useMousePosition.js
import { ref, onMounted, onUnmounted } from "vue"
export function useMousePosition() {
const x = ref(0)
const y = ref(0)
function update(event) {
x.value = event.pageX
y.value = event.pageY
}
onMounted(() => window.addEventListener("mousemove", update))
onUnmounted(() => window.removeEventListener("mousemove", update))
return { x, y }
}<script setup>
import { useMousePosition } from "@/composables/useMousePosition"
/* The origin of `x` and `y` is explicit at the call site. */
const { x, y } = useMousePosition()
</script>Components
Naming
Component names MUST be multi-word (TodoItem, not Item), except for root App component and components
provided by Vue itself (<Transition>, <KeepAlive>). This avoids collisions with existing and future HTML
elements, all of which are single-word.
Component file names MUST use PascalCase (TodoItem.vue), matching the name used to register and reference the
component. A base component — one that applies purely presentational, app-specific styling and conventions, with no
business logic — SHOULD be prefixed Base (BaseButton.vue, BaseIcon.vue), so that all such components sort
together and are visibly distinct from feature components.
In templates, a component MUST be referenced in PascalCase in single-file components (<TodoItem />), matching its
import. Self-closing form (<TodoItem />) MUST be used for components with no content, in both single-file
components and any .js/.ts render-function context.
Props
Prop names MUST use camelCase in <script> and kebab-case in <template>, following HTML’s own case-insensitivity
for attributes:
<script setup lang="ts">
defineProps<{
itemLabel: string
}>()
</script>
<template>
<!-- kebab-case in the template, camelCase in the script -->
<TodoItem :item-label="label" />
</template>Every prop SHOULD have as detailed a type as practical (string[], not Array; a defined interface, not object),
and MUST declare whether it is required or has a default. A boolean prop’s name SHOULD read as a question
(isVisible, hasError) so its meaning is unambiguous at the call site.
interface Props {
items: string[]
isVisible?: boolean
}
withDefaults(defineProps<Props>(), {
isVisible: false,
})Props MUST be treated as read-only within the component that receives them. Mutating a prop directly desyncs the
child’s local view of the value from the parent, which remains the source of truth and is not notified of the
change. A component that needs to modify a prop’s initial value locally MUST copy it into a local ref first:
const props = defineProps<{ initialValue: number }>()
/* No: mutates the prop directly. */
function increment() {
props.initialValue++ // TypeScript also rejects this at compile time.
}
/* Yes: local state seeded from the prop. */
const localValue = ref(props.initialValue)
function increment() {
localValue.value++
}Events
A component MUST communicate upward by emitting an event, declared with defineEmits(), not by mutating a prop or
reaching into the parent. Event names SHOULD use kebab-case in templates and SHOULD be verb phrases describing what
happened (update:modelValue, item-removed), not what the child wants the parent to do.
Slots
Content projection SHOULD use named slots, not a content or children prop, whenever a component needs to accept
arbitrary markup from its consumer. A scoped slot SHOULD be used when the parent’s slot content needs access to
data from the child.
<!-- List.vue -->
<template>
<ul>
<li v-for="item in items" :key="item.id">
<slot :item="item">{{ item.label }}</slot>
</li>
</ul>
</template><!-- Consumer -->
<List :items="items">
<template #default="{ item }">
<strong>{{ item.label }}</strong>
</template>
</List>v-for keys
v-for MUST always be paired with a :key binding, and the key MUST be a stable, unique identifier for the item —
never the loop index, except for a list that is provably static and never reordered, filtered, or spliced. An index
key silently misattributes component state to the wrong item across a reorder, because Vue’s diffing keys elements
by position rather than identity.
v-for and v-if MUST NOT be used on the same element. v-if has lower precedence than v-for in this position,
so Vue’s compiler raises a warning, and the intent is always better expressed by moving the condition — either onto
a wrapping <template>, or into a computed() that pre-filters the list before it reaches v-for.
<!-- No: v-if runs against every item, and precedence is a known footgun. -->
<li v-for="user in users" v-if="user.active" :key="user.id">
{{ user.name }}
</li>
<!-- Yes: filter before the loop. -->
<li v-for="user in activeUsers" :key="user.id">
{{ user.name }}
</li>State management
Local versus shared state
State that is used by a single component and its direct children SHOULD stay local, held in that component with
ref()/reactive() and passed down via props, with changes communicated back up via emitted events. Reach for a
shared store only once state needs to be read or written from parts of the component tree that do not have a
direct ancestor/descendant relationship — introducing a store for state that is really local adds an indirection
layer with no benefit, and makes the data flow harder to trace, not easier.
Pinia
Pinia is RECOMMENDED for shared application state. It is the official Vue state-management library, superseding Vuex, and integrates directly with the Composition API — a store’s `ref`s and `computed`s are its state and getters, with no separate mutation-commit ceremony.
// stores/cart.js
import { defineStore } from "pinia"
import { ref, computed } from "vue"
export const useCartStore = defineStore("cart", () => {
const items = ref([])
const total = computed(() =>
items.value.reduce((sum, item) => sum + item.price, 0),
)
function addItem(item) {
items.value.push(item)
}
return { items, total, addItem }
})A store’s state MUST NOT be mutated directly from outside the store except through an assignment to a whole ref;
a component that needs to change store state SHOULD do so by calling an action the store exposes (addItem()
above), keeping the mutation logic in one place rather than scattered across every consumer.
Store files SHOULD be named use<Name>Store.js (useCartStore.js), matching the exported composable’s name, and
grouped under a top-level stores/ directory. See Filesystem for where stores/ sits alongside a project’s
other top-level directories.
Provide/inject
provide()/inject() MAY be used to pass data down a component subtree without prop-drilling it through every
intermediate level, but is best suited to values a subtree needs implicitly — a theme, a locale, a form’s shared
validation context — not as a general substitute for a store. Data passed via provide()/inject() is not
reactive by default; provide a ref or reactive() object, not a plain value, if the injected data needs to
update reactively.
// Providing component
import { provide, ref } from "vue"
const theme = ref("dark")
provide("theme", theme)// Any descendant
import { inject } from "vue"
const theme = inject("theme")Filesystem
The following structure is RECOMMENDED as a starting point for a Vue application:
src/ ├── assets/ ├── components/ │ ├── base/ │ │ ├── BaseButton.vue │ │ └── BaseIcon.vue │ └── TodoItem.vue ├── composables/ │ ├── useMousePosition.js │ └── useFetch.js ├── stores/ │ ├── useCartStore.js │ └── useUserStore.js ├── views/ │ ├── HomeView.vue │ └── CheckoutView.vue ├── router/ │ └── index.js ├── App.vue └── main.js
components/holds reusable, presentational components. Abase/subdirectory groups components with no business logic (see Naming).composables/holds shared reactive logic extracted per Composables over mixins, one composable per file, named after its exported function (useMousePosition.jsexportsuseMousePosition()).stores/holds Pinia stores, one per file, named after the exported store composable (see Pinia).views/holds route-level components — the components rendered directly by the router. A view SHOULD be suffixedView(HomeView.vue) to distinguish it from a reusable component at a glance.router/holds the Vue Router configuration.
Category | File naming | Examples |
|---|---|---|
Components | PascalCase |
|
Views | PascalCase, suffixed |
|
Composables | camelCase, prefixed |
|
Stores | camelCase, prefixed |
|
A component that grows complex enough to warrant its own subcomponents, tests, and styles SHOULD be promoted to a
directory of its own (TodoItem/TodoItem.vue, colocated with TodoItem.spec.js and any subcomponents it owns),
rather than left as a single large file. This mirrors the same colocation trade-off described for React in
TS-41: React — colocating tests works well for large, complex projects, while a dedicated
top-level tests/ directory tends to suit smaller ones.
References
- Vue.js (2024). Vue.js Guide. — The official Vue documentation; the primary source for the Composition API, reactivity, and single-file-component guidance in this standard.
- Vue.js (2024). Vue Style Guide. — The official style guide; the source for the
naming, props, and
v-for/v-ifconventions in Components. - Pinia (2024). Pinia Documentation. — The official Vue state-management library; the source for the store conventions in State management.