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 on ref() 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 that ref()-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. A base/ 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.js exports useMousePosition()).
  • 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 suffixed View (HomeView.vue) to distinguish it from a reusable component at a glance.
  • router/ holds the Vue Router configuration.
Naming conventions

Category

File naming

Examples

Components

PascalCase

TodoItem.vue, BaseButton.vue

Views

PascalCase, suffixed View

HomeView.vue, CheckoutView.vue

Composables

camelCase, prefixed use

useMousePosition.js, useFetch.js

Stores

camelCase, prefixed use, suffixed Store

useCartStore.js, useUserStore.js

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-if conventions in Components.
  • Pinia (2024). Pinia Documentation. — The official Vue state-management library; the source for the store conventions in State management.