Skip to content

Priority A Rules: Essential

These rules help prevent errors, so learn and abide by them at all costs. Exceptions may exist, but should be very rare and only be made by those with expert knowledge of both JavaScript and Vue.

Use multi-word component names

User component names should always be multi-word, except for root App components. This prevents conflicts with existing and future HTML elements, since all HTML elements are a single word.

Bad

template
<!-- in pre-compiled templates -->
<Item />

<!-- in in-DOM templates -->
<item></item>

Good

template
<!-- in pre-compiled templates -->
<TodoItem />

<!-- in in-DOM templates -->
<todo-item></todo-item>

Use detailed prop definitions

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

Detailed prop definitions make a component's API easier to understand, easier to use correctly, and easier to change safely.

In TypeScript, a type-based defineProps() declaration can also be used when the prop contract is fully described by its types.

Bad

js
// This is only OK when prototyping
props: ['status']

Good

js
props: {
  status: String
}
js
// Even better!
props: {
  status: {
    type: String,
    required: true,

    validator: value => {
      return [
        'syncing',
        'synced',
        'version-conflict',
        'error'
      ].includes(value)
    }
  }
}

Bad

js
// This is only OK when prototyping
const props = defineProps(['status'])

Good

js
const props = defineProps({
  status: String
})
js
// Even better!
const props = defineProps({
  status: {
    type: String,
    required: true,

    validator: (value) => {
      return ['syncing', 'synced', 'version-conflict', 'error'].includes(
        value
      )
    }
  }
})

Declare emitted events

Treat emitted events as part of a component's public contract, and declare them explicitly.

Why this matters

Explicit event declarations document how a component communicates outward and make parent-child interactions easier to follow.

In Options API, declare events with the emits option. In <script setup>, declare them with defineEmits().

Use the object syntax when event payloads need validation, and an array of event names when they do not.

In TypeScript, a typed defineEmits() declaration can also be used so the event contract is checked by the type system as well as documented in the component. Vue 3.3+ also supports a named tuple syntax for the same declaration.

Bad

js
export default {
  methods: {
    submit(email) {
      this.$emit('submit', { email })
    }
  }
}

Good

js
export default {
  emits: {
    submit: (payload) => typeof payload?.email === 'string'
  },

  methods: {
    submit(email) {
      this.$emit('submit', { email })
    }
  }
}

Bad

vue
<template>
  <button @click="$emit('submit')">Submit</button>
</template>

Good

vue
<script setup>
defineEmits(['submit'])
</script>

<template>
  <button @click="$emit('submit')">Submit</button>
</template>
vue
<script setup lang="ts">
const form = {
  email: ''
}

const emit = defineEmits<{
  // Type-based declaration
  (e: 'submit', payload: { email: string }): void
  // Vue 3.3+: alternative, more succinct syntax
  // submit: [payload: { email: string }]
}>()
</script>

<template>
  <button @click="emit('submit', { email: form.email })">Submit</button>
</template>

Keep parent-child data flow explicit

Pass data down with props, and communicate requested changes back up with emitted events.

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

js
export default {
  props: {
    open: Boolean
  },

  methods: {
    requestClose() {
      this.open = false
    }
  }
}

Good

js
export default {
  props: {
    open: Boolean
  },

  emits: ['update:open'],

  methods: {
    requestClose() {
      this.$emit('update:open', false)
    }
  }
}

Bad

js
const props = defineProps({
  open: Boolean
})

function requestClose() {
  props.open = false
}

Good

js
const props = defineProps({
  open: Boolean
})

const emit = defineEmits(['update:open'])

function requestClose() {
  emit('update:open', false)
}

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

template
<template>
  <button class="btn btn-close">×</button>
</template>

<style>
.btn-close {
  background-color: red;
}
</style>

Good

template
<template>
  <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>
template
<template>
  <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 scoped attribute 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.

Use computed for derived state

Use computed for synchronous derived state instead of storing and synchronizing it manually, and use watch, watchEffect(), or lifecycle hooks for side effects.

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.

Bad

js
export default {
  computed: {
    fullName() {
      const value = `${this.user.firstName} ${this.user.lastName}`
      analytics.track('full-name-changed', value)
      return value
    }
  }
}

Good

js
export default {
  computed: {
    fullName() {
      return `${this.user.firstName} ${this.user.lastName}`
    }
  },

  watch: {
    fullName(value) {
      analytics.track('full-name-changed', value)
    }
  }
}

Bad

js
const fullName = computed(() => {
  const value = `${user.value.firstName} ${user.value.lastName}`
  analytics.track('full-name-changed', value)
  return value
})

Good

js
const fullName = computed(() => {
  return `${user.value.firstName} ${user.value.lastName}`
})

watch(fullName, (value) => {
  analytics.track('full-name-changed', value)
})
Priority A Rules: Essential has loaded