
Vue
- 7k installs
- 696 repo stars
- Updated July 27, 2026
- onmax/nuxt-skills
vue is an agent skill for Use when editing .vue files, creating Vue 3 components, writing composables, or testing Vue code - provides Composition API patterns, props/emits best practices, VueUse integration
About
Use when editing vue files creating Vue 3 components writing composables or testing Vue code provides Composition API patterns props emits best practices VueUse integration and reactive destructuring guidance name vue description Use when editing vue files creating Vue 3 components writing composables or testing Vue code provides Composition API patterns props emits best practices VueUse integration and reactive destructuring guidance license MIT Vue 3 Development Reference for Vue 3 Composition API patterns component architecture and testing practices Current stable Vue 3 5 with enhanced reactivity performance 56 memory 10x faster array tracking new SSR features and improved developer experience Overview Progressive reference system for Vue 3 projects Load only files relevant to current task to minimize context usage 250 tokens base 500-1500 per sub-file When to Use Use this skill when Writing vue components Creating composables use functions Building client-side utilities Testing Vue components composables Use nuxt skill instead for Server routes API endpoints File-based routing middleware Nuxt-specific patterns For styled UI components use nuxt-ui skill For headless accessible
- Writing `.vue` components
- Creating composables (`use*` functions)
- Building client-side utilities
- Testing Vue components/composables
- Server routes, API endpoints
Vue by the numbers
- 6,978 all-time installs (skills.sh)
- +53 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #226 of 2,153 Testing & QA skills by installs in the Skillselion catalog
- Security screen: LOW risk (skills.sh audit)
- Data as of Aug 4, 2026 (Skillselion catalog sync)
vue capabilities & compatibility
- Capabilities
- writing `.vue` components · creating composables (`use*` functions) · building client side utilities · testing vue components/composables · server routes, api endpoints
- Use cases
- documentation
What vue says it does
**Current stable:** Vue 3.5+ with enhanced reactivity performance (-56% memory, 10x faster array tracking), new SSR features, and improved developer experience.
## Overview Progressive reference system for Vue 3 projects.
Load only files relevant to current task to minimize context usage (~250 tokens base, 500-1500 per sub-file).
npx skills add https://github.com/onmax/nuxt-skills --skill vueAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 7k |
|---|---|
| repo stars | ★ 696 |
| Security audit | 3 / 3 scanners passed |
| Last updated | July 27, 2026 |
| Repository | onmax/nuxt-skills ↗ |
When should developers use vue and what problem does it solve?
Use when editing .vue files, creating Vue 3 components, writing composables, or testing Vue code - provides Composition API patterns, props/emits best practices, VueUse integration, and reactive destr
Who is it for?
Developers working with vue patterns described in the skill documentation.
Skip if: Skip when cached docs are empty or the task is outside the skill's documented scope.
When should I use this skill?
Use when editing .vue files, creating Vue 3 components, writing composables, or testing Vue code - provides Composition API patterns, props/emits best practices, VueUse integration, and reactive destr
What you get
Grounded guidance and workflows from SKILL.md for vue.
- Vue 3 component templates
- typed props and emits patterns
- script setup boilerplate
Files
Vue 3 Development
Reference for Vue 3 Composition API patterns, component architecture, and testing practices.
Current stable: Vue 3.5+ with enhanced reactivity performance (-56% memory, 10x faster array tracking), new SSR features, and improved developer experience.
Overview
Progressive reference system for Vue 3 projects. Load only files relevant to current task to minimize context usage (~250 tokens base, 500-1500 per sub-file).
When to Use
Use this skill when:
- Writing
.vuecomponents - Creating composables (
use*functions) - Building client-side utilities
- Testing Vue components/composables
Use `nuxt` skill instead for:
- Server routes, API endpoints
- File-based routing, middleware
- Nuxt-specific patterns
For styled UI components: use nuxt-ui skill For headless accessible components: use reka-ui skill For VueUse composables: use vueuse skill
Quick Reference
| Working on... | Load file |
|---|---|
.vue in components/ | references/components.md |
File in composables/ | references/composables.md |
File in utils/ | references/utils-client.md |
.spec.ts or .test.ts | references/testing.md |
| TypeScript patterns | references/typescript.md |
| Vue Router typing | references/router.md |
| Reactivity (ref, watch) | references/reactivity.md |
| Custom directives | references/directives.md |
| Provide/inject | references/provide-inject.md |
| Edge cases, vue-tsc | references/gotchas.md |
Loading Files
Consider loading these reference files based on your task:
- [ ] references/components.md - if working in
components/or writing.vuefiles - [ ] references/composables.md - if creating composables (
use*functions) - [ ] references/utils-client.md - if working in
utils/or writing client utilities - [ ] references/testing.md - if writing
.spec.tsor.test.tsfiles - [ ] references/typescript.md - if working with Vue TypeScript patterns or generics
- [ ] references/router.md - if working with Vue Router or route typing
- [ ] references/reactivity.md - if using ref, reactive, computed, watch, or watchEffect
- [ ] references/directives.md - if creating or using custom directives
- [ ] references/provide-inject.md - if using provide/inject patterns
- [ ] references/gotchas.md - if debugging edge cases or hydration issues
DO NOT load all files at once. Load only what's relevant to your current task.
Quick Start
<script setup lang="ts">
const { count = 0 } = defineProps<{ count?: number }>()
const emit = defineEmits<{ update: [value: number] }>()
</script>
<template>
<button @click="emit('update', count + 1)">
Count: {{ count }}
</button>
</template>Available Guidance
[references/components.md](references/components.md) - Props with reactive destructuring, emits patterns, defineModel for v-model, slots shorthand
[references/composables.md](references/composables.md) - Composition API structure, VueUse integration, lifecycle hooks, async patterns, reactivity gotchas
[references/utils-client.md](references/utils-client.md) - Pure functions, formatters, validators, transformers, when NOT to use utils
[references/testing.md](references/testing.md) - Vitest + @vue/test-utils, component testing, composable testing, router mocking
[references/typescript.md](references/typescript.md) - InjectionKey for provide/inject, vue-tsc strict templates, tsconfig settings, generic components
[references/router.md](references/router.md) - Route meta types, typed params with unplugin-vue-router, scroll behavior, navigation guards
[references/reactivity.md](references/reactivity.md) - ref, reactive, computed, watch, watchEffect, reactivity fundamentals
[references/directives.md](references/directives.md) - Custom directive hooks, v-focus, v-click-outside, v-tooltip patterns
[references/provide-inject.md](references/provide-inject.md) - InjectionKey typing, app-level provide, readonly patterns
[references/gotchas.md](references/gotchas.md) - Common gotchas, vue-tsc edge cases, hydration issues, race conditions (from vuejs-ai/skills)
Vue Components
Patterns for Vue 3 components using Composition API with <script setup>.
Quick Reference
| Pattern | Syntax |
|---|---|
| Props (destructured) | const { name = 'default' } = defineProps<{ name?: string }>() |
| Props (template-only) | defineProps<{ name: string }>() |
| Emits | const emit = defineEmits<{ click: [id: number] }>() |
| Two-way binding | const model = defineModel<string>() |
| Slots shorthand | <template #header> not <template v-slot:header> |
Naming
Files: PascalCase (UserProfile.vue) OR kebab-case (user-profile.vue) - be consistent
Component names in code: Always PascalCase
Composition: General → Specific: SearchButtonClear.vue not ClearSearchButton.vue
Props
Destructure with defaults (Vue 3.5+) when used in script or need defaults:
const { count = 0, message = 'Hello' } = defineProps<{
count?: number
message?: string
required: boolean
}>()
// Use directly - maintains reactivity
console.log(count + 1)
// ⚠️ When passing to watchers/functions, wrap in getter:
watch(() => count, (newVal) => { ... }) // ✅ Correct
watch(count, (newVal) => { ... }) // ❌ Won't workNon-destructured only if props ONLY used in template:
defineProps<{ count: number }>()
// Template: {{ count }}Same-name shorthand (Vue 3.4+): :count instead of :count="count"
<MyComponent :count :user :items />
<!-- Same as: :count="count" :user="user" :items="items" -->Emits
Type-safe event definitions:
const emit = defineEmits<{
update: [id: number, value: string] // multiple args
close: [] // no args
}>()
// Usage
emit('update', 123, 'new value')
emit('close')Template syntax: kebab-case (@update-item) vs camelCase in script (updateItem)
Slots
Always use shorthand: <template #header> not <template v-slot:header>
Always explicit `<template>` tags for all slots
<template>
<Card>
<template #header>
<h2>Title</h2>
</template>
<template #default>
Content
</template>
</Card>
</template>defineModel() - Two-Way Binding
Replaces manual modelValue prop + update:modelValue emit.
Basic
<script setup lang="ts">
const title = defineModel<string>()
</script>
<template>
<input v-model="title">
</template>With Options
<script setup lang="ts">
const [title, modifiers] = defineModel<string>({
default: 'default value',
required: true,
get: (value) => value.trim(),
set: (value) => {
if (modifiers.capitalize) {
return value.charAt(0).toUpperCase() + value.slice(1)
}
return value
},
})
</script>⚠️ Warning: When using default without parent providing a value, parent and child can de-sync (parent undefined, child has default). Always provide matching defaults in parent or make prop required.
Prevent double-emit with `required: true`:
// ❌ Without required - emits twice (undefined then value)
const model = defineModel<Item>()
// ✅ With required - single emit
const model = defineModel<Item>({ required: true })Use required: true when the model should always have a value to avoid the double-emit issue during initialization.
Multiple Models
Default assumes modelValue prop. For multiple bindings, use explicit names:
<script setup lang="ts">
const firstName = defineModel<string>('firstName')
const age = defineModel<number>('age')
</script>
<!-- Usage -->
<UserForm v-model:first-name="user.firstName" v-model:age="user.age" />Reusable Templates
For typed, scoped template snippets within a component:
<script setup lang="ts">
import { createReusableTemplate } from '@vueuse/core'
const [DefineItem, UseItem] = createReusableTemplate<{
item: SearchItem
icon: string
color?: 'red' | 'green' | 'blue'
}>()
</script>
<template>
<DefineItem v-slot="{ item, icon, color }">
<div :class="color">
<Icon :name="icon" />
{{ item.name }}
</div>
</DefineItem>
<!-- Reuse multiple times -->
<UseItem v-for="item in items" :key="item.id" :item :icon="getIcon(item)" />
</template>Template Refs (Vue 3.5+)
Use useTemplateRef() for type-safe template references with IDE support:
<script setup lang="ts">
import { useTemplateRef, onMounted } from 'vue'
const input = useTemplateRef<HTMLInputElement>('my-input')
onMounted(() => {
input.value?.focus()
})
</script>
<template>
<input ref="my-input">
</template>Benefits over `ref()`:
- Type-safe with generics
- Better IDE autocomplete and refactoring
- Explicit ref name as string literal
Dynamic refs:
<script setup lang="ts">
const items = ref(['a', 'b', 'c'])
const itemRefs = useTemplateRef<HTMLElement>('item')
// Access refs after mount
onMounted(() => {
console.log(itemRefs.value) // Array of elements
})
</script>
<template>
<div v-for="item in items" :key="item" ref="item">
{{ item }}
</div>
</template>Component refs with generics:
For generic components, use ComponentExposed from vue-component-type-helpers:
import type { ComponentExposed } from 'vue-component-type-helpers'
import MyGenericComponent from './MyGenericComponent.vue'
// Get exposed methods/properties with correct generic types
const compRef = useTemplateRef<ComponentExposed<typeof MyGenericComponent>>('comp')
onMounted(() => {
compRef.value?.someExposedMethod() // Typed!
})Install: pnpm add -D vue-component-type-helpers
SSR Hydration (Vue 3.5+)
Suppress hydration mismatches for values that differ between server/client:
<template>
<!-- Client-side only values -->
<span data-allow-mismatch>{{ new Date().toLocaleString() }}</span>
<!-- Specific mismatch types -->
<span data-allow-mismatch="text">{{ timestamp }}</span>
<span data-allow-mismatch="children">
<ClientOnly>...</ClientOnly>
</span>
<span data-allow-mismatch="style">...</span>
<span data-allow-mismatch="class">...</span>
<span data-allow-mismatch="attribute">...</span>
</template>Generate SSR-stable IDs:
<script setup lang="ts">
import { useId } from 'vue'
const id = useId() // Stable across server/client renders
</script>
<template>
<label :for="id">Name</label>
<input :id="id">
</template>Deferred Teleport (Vue 3.5+)
Teleport to elements rendered later in the same cycle:
<template>
<!-- This renders first -->
<Teleport defer to="#late-div">
<span>Deferred content</span>
</Teleport>
<!-- This renders after, but Teleport waits -->
<div id="late-div"></div>
</template>Without defer, teleport to #late-div would fail since it doesn't exist yet.
Common Mistakes
Using `const props =` with destructured values:
// ❌ Wrong
const props = defineProps<{ count: number }>()
const { count } = props // Loses reactivityForgetting TypeScript types:
// ❌ Wrong
const emit = defineEmits(['update'])
// ✅ Correct
const emit = defineEmits<{ update: [id: number] }>()Components >300 lines: Split into smaller components or extract logic to composables
Vue Composables
Reusable functions encapsulating stateful logic using Composition API.
Core Rules
1. VueUse first - check vueuse.org before writing custom 2. No async composables - lose lifecycle context when awaited in other composables 3. Top-level only - never call in event handlers, conditionals, or loops 4. readonly() exports - protect internal state from external mutation 5. useState() for SSR - use Nuxt's useState() not global refs
Quick Reference
| Pattern | Example |
|---|---|
| Naming | useAuth, useCounter, useDebounce |
| State | const count = ref(0) |
| Computed | const double = computed(() => count.value * 2) |
| Lifecycle | onMounted(() => ...), onUnmounted(() => ...) |
| Return | return { count, increment } |
Structure
// composables/useCounter.ts
import { readonly, ref } from 'vue'
export function useCounter(initialValue = 0) {
const count = ref(initialValue)
function increment() { count.value++ }
function decrement() { count.value-- }
function reset() { count.value = initialValue }
return {
count: readonly(count), // readonly if shouldn't be mutated
increment,
decrement,
reset,
}
}Naming
Always prefix with `use`: useAuth, useLocalStorage, useDebounce
File = function: useAuth.ts exports useAuth
Best Practices
Do:
- Return object with named properties (destructuring-friendly)
- Accept options object for configuration
- Use
readonly()for state that shouldn't mutate - Handle cleanup (
onUnmounted,onScopeDispose) - Add JSDoc for complex functions
Lifecycle
Hooks execute in component context:
export function useEventListener(target: EventTarget, event: string, handler: Function) {
onMounted(() => target.addEventListener(event, handler))
onUnmounted(() => target.removeEventListener(event, handler))
}Watcher cleanup (Vue 3.5+):
import { watch, onWatcherCleanup } from 'vue'
export function usePolling(url: Ref<string>) {
watch(url, (newUrl) => {
const interval = setInterval(() => {
fetch(newUrl).then(/* ... */)
}, 1000)
// Cleanup when watcher re-runs or stops
onWatcherCleanup(() => {
clearInterval(interval)
})
})
}Benefits of `onWatcherCleanup()`:
- Cleaner than returning cleanup functions
- Works with async watchers
- Can be called multiple times in same watcher
Async Pattern
export function useAsyncData<T>(fetcher: () => Promise<T>) {
const data = ref<T | null>(null)
const error = ref<Error | null>(null)
const loading = ref(false)
async function execute() {
loading.value = true
error.value = null
try {
data.value = await fetcher()
}
catch (e) {
error.value = e as Error
}
finally {
loading.value = false
}
}
execute()
return { data, error, loading, refetch: execute }
}Data fetching: Prefer Pinia Colada queries over custom composables.
VueUse
For VueUse composable reference, use the vueuse skill.Check VueUse before writing custom composables - most patterns already implemented.
For Nuxt-specific composables (useFetch, useRequestURL): see nuxt skill nuxt-composables.mdAdvanced Patterns
Singleton Composable
Share state across all components using the same composable:
import { createSharedComposable } from '@vueuse/core'
function useMapControlsBase() {
const mapInstance = ref<Map | null>(null)
const flyTo = (coords: [number, number]) => mapInstance.value?.flyTo(coords)
return { mapInstance, flyTo }
}
export const useMapControls = createSharedComposable(useMapControlsBase)Cancellable Fetch with AbortController
export function useSearch() {
let abortController: AbortController | null = null
watch(query, async (newQuery) => {
abortController?.abort()
abortController = new AbortController()
try {
const data = await $fetch('/api/search', {
query: { q: newQuery },
signal: abortController.signal,
})
}
catch (e) {
if (e.name !== 'AbortError')
throw e
}
})
}Step-Based State Machine
export function useSendFlow() {
const step = ref<'input' | 'confirm' | 'success'>('input')
const amount = ref('')
const next = () => {
if (step.value === 'input')
step.value = 'confirm'
else if (step.value === 'confirm')
step.value = 'success'
}
return { step, amount, next }
}Client-Only Guards
export function useUserLocation() {
const location = ref<GeolocationPosition | null>(null)
if (import.meta.client) {
navigator.geolocation.getCurrentPosition(pos => location.value = pos)
}
return { location }
}Custom Element Composables (Vue 3.5+)
For custom element components, use built-in helpers:
import { useHost, useShadowRoot } from 'vue'
export function useCustomElement() {
const host = useHost() // Host element reference
const shadowRoot = useShadowRoot() // Shadow DOM root
onMounted(() => {
console.log('Host:', host)
console.log('Shadow:', shadowRoot)
})
return { host, shadowRoot }
}Available in:
- Components using
<script setup>in custom elements - Access via
this.$hostin Options API
Auto-Save with Debounce
export function useAutoSave(content: Ref<string>) {
const hasChanges = ref(false)
const save = useDebounceFn(async () => {
if (!hasChanges.value)
return
await $fetch('/api/save', { method: 'POST', body: { content: content.value } })
hasChanges.value = false
}, 1000)
watch(content, () => {
hasChanges.value = true
save()
})
return { hasChanges }
}Tagged Logger
import { consola } from 'consola'
export function useSearch() {
const logger = consola.withTag('search')
watch(query, (q) => {
logger.info('Query changed:', q)
})
}Reactivity Gotchas
Ref Unwrapping in Reactive
Refs auto-unwrap in reactive() objects but NOT in arrays, Maps, or Sets:
// ✅ Object - auto unwraps
const state = reactive({ count: ref(0) })
state.count++ // No .value needed
// ❌ Array - NO unwrapping
const arr = reactive([ref(1)])
arr[0].value // Need .value!
// ❌ Map/Set - NO unwrapping
const map = reactive(new Map([['key', ref(1)]]))
map.get('key').value // Need .value!watchEffect Conditional Tracking
Dependencies inside conditional branches are not tracked when condition is false:
// ❌ Wrong - dep not tracked when condition false
watchEffect(() => {
if (condition.value) {
console.log(dep.value) // Only tracked when condition=true
}
})
// ✅ Correct - use explicit watch for conditional deps
watch([condition, dep], ([cond, d]) => {
if (cond) console.log(d)
})Cleanup Patterns
For keep-alive components - use onDeactivated:
export function usePolling() {
let interval: NodeJS.Timeout
onMounted(() => { interval = setInterval(poll, 5000) })
onUnmounted(() => clearInterval(interval))
onDeactivated(() => clearInterval(interval)) // Pause when deactivated
onActivated(() => { interval = setInterval(poll, 5000) }) // Resume
}For scope-aware cleanup - use tryOnScopeDispose from VueUse:
import { tryOnScopeDispose } from '@vueuse/core'
export function useEventSource(url: string) {
const source = new EventSource(url)
// Cleans up when effect scope disposes (component unmount, watcher stop)
tryOnScopeDispose(() => source.close())
return { source }
}Common Mistakes
Not using `readonly()` for internal state:
// ❌ Wrong - exposes mutable ref
return { count }
// ✅ Correct - prevents external mutation
return { count: readonly(count) }Missing cleanup:
// ❌ Wrong - listener never removed
onMounted(() => target.addEventListener('click', handler))
// ✅ Correct - cleanup on unmount
onMounted(() => target.addEventListener('click', handler))
onUnmounted(() => target.removeEventListener('click', handler))Custom Directives
Custom directives provide low-level DOM access for reusable behavior.
When to Use
Use custom directives when:
- You need direct DOM manipulation
- The behavior can't be achieved with components or composables
- You need to apply behavior to native elements
Basic Example
<script setup lang="ts">
// v-focus directive
const vFocus = {
mounted: (el: HTMLElement) => el.focus()
}
</script>
<template>
<input v-focus />
</template>Directive Hooks
const myDirective = {
// Before element attributes/listeners are applied
created(el, binding, vnode) {},
// Before element is inserted into DOM
beforeMount(el, binding, vnode) {},
// After element and children are mounted
mounted(el, binding, vnode) {},
// Before parent component updates
beforeUpdate(el, binding, vnode, prevVnode) {},
// After parent component updates
updated(el, binding, vnode, prevVnode) {},
// Before parent component unmounts
beforeUnmount(el, binding, vnode) {},
// After parent component unmounts
unmounted(el, binding, vnode) {}
}Hook Arguments
interface DirectiveBinding<T = any> {
value: T // v-my-dir="value"
oldValue: T // Previous value (beforeUpdate/updated only)
arg?: string // v-my-dir:arg
modifiers: Record<string, boolean> // v-my-dir.foo.bar → { foo: true, bar: true }
instance: ComponentPublicInstance // Component using the directive
dir: ObjectDirective // Directive definition object
}Example usage:
<div v-example:foo.bar="baz">// binding object:
{
arg: 'foo',
modifiers: { bar: true },
value: /* value of baz */,
oldValue: /* previous value */
}Function Shorthand
When you only need mounted and updated with same behavior:
// Full form
const vColor = {
mounted(el, binding) {
el.style.color = binding.value
},
updated(el, binding) {
el.style.color = binding.value
}
}
// Shorthand (same behavior)
const vColor = (el: HTMLElement, binding: DirectiveBinding<string>) => {
el.style.color = binding.value
}Global Registration
// main.ts
const app = createApp(App)
app.directive('focus', {
mounted: (el) => el.focus()
})
// Shorthand
app.directive('color', (el, binding) => {
el.style.color = binding.value
})Object Literals
Pass multiple values:
<div v-demo="{ color: 'white', text: 'hello' }">const vDemo = (el: HTMLElement, binding: DirectiveBinding<{ color: string; text: string }>) => {
console.log(binding.value.color) // 'white'
console.log(binding.value.text) // 'hello'
}Dynamic Arguments
<div v-my-directive:[dynamicArg]="value">Practical Examples
v-click-outside
const vClickOutside = {
mounted(el: HTMLElement, binding: DirectiveBinding<() => void>) {
el._clickOutside = (event: MouseEvent) => {
if (!el.contains(event.target as Node)) {
binding.value()
}
}
document.addEventListener('click', el._clickOutside)
},
unmounted(el: HTMLElement) {
document.removeEventListener('click', el._clickOutside)
}
}v-tooltip
const vTooltip = {
mounted(el: HTMLElement, binding: DirectiveBinding<string>) {
el.setAttribute('title', binding.value)
},
updated(el: HTMLElement, binding: DirectiveBinding<string>) {
el.setAttribute('title', binding.value)
}
}v-permission
const vPermission = {
mounted(el: HTMLElement, binding: DirectiveBinding<string>) {
if (!hasPermission(binding.value)) {
el.parentNode?.removeChild(el)
}
}
}TypeScript: Global Directives
// directives/highlight.ts
import type { Directive } from 'vue'
export type HighlightDirective = Directive<HTMLElement, string>
declare module 'vue' {
export interface ComponentCustomProperties {
vHighlight: HighlightDirective
}
}
export default {
mounted: (el, binding) => {
el.style.backgroundColor = binding.value
}
} satisfies HighlightDirectiveUsage on Components
⚠️ Not recommended - directives apply to root element, which can be unpredictable with multi-root components.
<!-- Applies to MyComponent's root element -->
<MyComponent v-my-directive /><!-- Source references:
- https://vuejs.org/guide/reusability/custom-directives.html
-->
Vue Common Gotchas & Edge Cases
Critical Vue 3 gotchas that cause silent failures or hard-to-debug issues.
Based on vuejs-ai/skills vue-best-practices. For comprehensive coverage (200+ rules), see the upstream repo.
Reactivity
Always Use .value When Accessing ref() in Scripts
Impact: HIGH - Forgetting .value causes silent failures.
const count = ref(0)
// WRONG
count++ // Tries to increment the ref object
count = 5 // Reassigns variable, loses reactivity
items.push(4) // Error: push is not a function
// CORRECT
count.value++
count.value = 5
items.value.push(4)
// In templates - NO .value needed (Vue unwraps automatically)
// {{ count }} works, not {{ count.value }}Never Destructure reactive() Objects Directly
Impact: HIGH - Destructuring breaks reactive connection.
const state = reactive({ count: 0, name: 'Vue' })
// WRONG - destructured variables lose reactivity
const { count, name } = state
state.count++
console.log(count) // Still 0!
// CORRECT - use toRefs()
const { count, name } = toRefs(state)
state.count++
console.log(count.value) // 1
// BEST - just use ref() instead of reactive()
const count = ref(0)
const name = ref('Vue')Proxy Identity Hazard with reactive()
const raw = {}
const proxy = reactive(raw)
// WRONG - comparing different objects
console.log(proxy === raw) // false
// WRONG - creating multiple proxies
const a = reactive({})
const b = reactive(a) // Returns same proxy
console.log(a === b) // true (same object)
// GOTCHA - nested objects get proxied too
const nested = reactive({ obj: {} })
console.log(nested.obj === nested.obj) // true (same proxy)Computed Properties
No Side Effects in Computed Getters
Impact: HIGH - Side effects break reactivity model.
// WRONG - mutates state
const doubled = computed(() => {
count.value++ // Side effect!
return count.value * 2
})
// WRONG - async operation
const data = computed(async () => {
return await fetch('/api') // Side effect!
})
// CORRECT - pure computation only
const doubled = computed(() => count.value * 2)
// For side effects, use watch:
watch(count, (newVal) => {
document.title = `Count: ${newVal}`
})Computed Returns Are Read-Only
const fullName = computed(() => `${first.value} ${last.value}`)
// WRONG - computed values are read-only
fullName.value = 'John Doe' // Error!
// CORRECT - use writable computed
const fullName = computed({
get: () => `${first.value} ${last.value}`,
set: (val) => {
const [f, l] = val.split(' ')
first.value = f
last.value = l
}
})Watchers
Clean Up Async Operations to Prevent Race Conditions
Impact: HIGH - Stale requests can overwrite newer data.
const query = ref('')
const results = ref([])
// WRONG - race condition
watch(query, async (q) => {
const res = await fetch(`/api?q=${q}`)
results.value = await res.json() // May overwrite newer results!
})
// CORRECT - use onWatcherCleanup (Vue 3.5+)
watch(query, async (q) => {
const controller = new AbortController()
onWatcherCleanup(() => controller.abort())
try {
const res = await fetch(`/api?q=${q}`, { signal: controller.signal })
results.value = await res.json()
} catch (e) {
if (e.name !== 'AbortError') throw e
}
})
// Or use onCleanup parameter
watch(query, async (q, oldQ, onCleanup) => {
const controller = new AbortController()
onCleanup(() => controller.abort())
// ... same as above
})Deep Watch Returns Same Object Reference
const obj = reactive({ nested: { count: 0 } })
// GOTCHA - oldValue === newValue for deep watches
watch(obj, (newVal, oldVal) => {
console.log(newVal === oldVal) // true! Same object
}, { deep: true })
// If you need old value, clone first:
watch(
() => structuredClone(obj),
(newVal, oldVal) => { /* now different */ }
)Props
Props Are Read-Only - Never Mutate
Impact: HIGH - Breaks one-way data flow.
const props = defineProps<{ count: number; user: User }>()
// WRONG - direct mutation
props.count++ // Vue warning
props.user.name = 'New' // No warning but still wrong!
// CORRECT - emit to parent
const emit = defineEmits(['update:count', 'update-user'])
emit('update:count', props.count + 1)
emit('update-user', { ...props.user, name: 'New' })
// Or create local copy
const localUser = ref({ ...props.user })Destructured Props Don't Update Watchers (pre-3.5)
// WRONG (Vue < 3.5)
const { count } = defineProps<{ count: number }>()
watch(count, () => {}) // Won't trigger!
// CORRECT - use getter
const props = defineProps<{ count: number }>()
watch(() => props.count, () => {})
// Vue 3.5+ - destructuring works with reactive props
const { count } = defineProps<{ count: number }>()
watch(() => count, () => {}) // Works in 3.5+Lifecycle Hooks
Register Hooks Synchronously During Setup
Impact: HIGH - Async hooks silently fail.
// WRONG - hook registered after await
async setup() {
const data = await fetchData()
onMounted(() => {}) // Will NEVER run!
}
// WRONG - hook in setTimeout
setup() {
setTimeout(() => {
onMounted(() => {}) // Will NEVER run!
}, 100)
}
// CORRECT - register synchronously, async inside
setup() {
onMounted(async () => {
const data = await fetchData()
})
}Templates
Never Use v-if with v-for on Same Element
Impact: HIGH - Vue 2/3 precedence differs.
<!-- WRONG - ambiguous precedence -->
<li v-for="user in users" v-if="user.active" :key="user.id">
<!-- Vue 3: v-if runs FIRST, 'user' undefined! -->
<!-- CORRECT - computed filter -->
<li v-for="user in activeUsers" :key="user.id">
<script setup>
const activeUsers = computed(() => users.filter(u => u.active))
</script>
<!-- CORRECT - template wrapper -->
<template v-for="user in users" :key="user.id">
<li v-if="user.active">{{ user.name }}</li>
</template>Template Refs Are Null with v-if
const inputRef = ref<HTMLInputElement | null>(null)
// GOTCHA - ref is null when element hidden
<input v-if="show" ref="inputRef" />
// WRONG - may be null
inputRef.value.focus() // Error if !show
// CORRECT - null check
inputRef.value?.focus()
// Or use watchEffect with flush: 'post'
watchEffect(() => {
inputRef.value?.focus()
}, { flush: 'post' })defineModel
Object Mutations Don't Emit
const model = defineModel<{ name: string }>()
// WRONG - mutation doesn't notify parent
model.value.name = 'New' // Parent won't know!
// CORRECT - replace entire object
model.value = { ...model.value, name: 'New' }Updated Value Needs nextTick
const model = defineModel<string>()
// WRONG - value not updated yet
model.value = 'new'
console.log(model.value) // Still old value!
// CORRECT - wait for nextTick
model.value = 'new'
await nextTick()
console.log(model.value) // Now 'new'Component Events
Undeclared Emits Can Fire Twice
// WRONG - missing emit declaration causes double firing
const emit = defineEmits([]) // 'click' not declared
<button @click="emit('click')"> // Fires twice!
// CORRECT - declare all custom events
const emit = defineEmits(['click'])Events Don't Bubble Through Components
<!-- Parent can't listen to grandchild events directly -->
<Grandparent>
<Parent>
<Child @custom="handler" /> <!-- Only Parent can listen -->
</Parent>
</Grandparent>
<!-- Solution: re-emit or use provide/inject -->Provide/Inject
Reactivity Not Automatic
// Provider
const count = ref(0)
provide('count', count) // Pass the ref, not .value
// Consumer
const count = inject('count') // Receives the ref
console.log(count.value) // Reactive!
// WRONG - loses reactivity
provide('count', count.value) // Just passes numberMust Call Provide Synchronously
// WRONG - provide after async
async setup() {
await fetchData()
provide('key', value) // Silently fails!
}
// CORRECT
setup() {
provide('key', value) // Synchronous
onMounted(async () => {
await fetchData()
})
}SSR
Lifecycle Hooks Don't Run on Server
// onMounted, onUpdated, onUnmounted - client only
onMounted(() => {
// Only runs in browser
window.addEventListener('resize', handler)
})
// For SSR, use onServerPrefetch for data
onServerPrefetch(async () => {
data.value = await fetchData()
})Hydration Mismatch Causes
Common causes:
- Browser-only APIs (
window,localStorage) - Different timestamps
- Random values
- User-agent specific rendering
// WRONG
const width = ref(window.innerWidth) // undefined on server
// CORRECT
const width = ref(0)
onMounted(() => {
width.value = window.innerWidth
})Performance
Use shallowRef for Large Non-Reactive Data
// WRONG - deep reactivity overhead
const hugeList = ref(thousandsOfItems)
// CORRECT - only track .value assignment
const hugeList = shallowRef(thousandsOfItems)
// Trigger update by replacing entire array
hugeList.value = [...hugeList.value, newItem]markRaw for Non-Reactive Objects
// WRONG - Chart.js instance becomes reactive (breaks it)
const chart = ref(new Chart(ctx, config))
// CORRECT - mark as non-reactive
const chart = ref(markRaw(new Chart(ctx, config)))References
- vuejs-ai/skills vue-best-practices - Full 200+ rules
- Vue Style Guide
- Vue 3 Migration Guide
Provide / Inject
Provide data from ancestor components to any descendant, avoiding prop drilling.
Basic Usage
<!-- Provider.vue -->
<script setup lang="ts">
import { provide, ref } from 'vue'
const message = ref('hello')
provide('message', message)
</script><!-- DeepChild.vue (any level deep) -->
<script setup lang="ts">
import { inject } from 'vue'
const message = inject('message')
</script>Typing with InjectionKey
Use InjectionKey for type safety between provider and injector:
// keys.ts
import type { InjectionKey, Ref } from 'vue'
export const messageKey = Symbol() as InjectionKey<Ref<string>>
export const countKey = Symbol() as InjectionKey<number><!-- Provider.vue -->
<script setup lang="ts">
import { provide, ref } from 'vue'
import { messageKey } from './keys'
const message = ref('hello')
provide(messageKey, message)
</script><!-- Injector.vue -->
<script setup lang="ts">
import { inject } from 'vue'
import { messageKey } from './keys'
const message = inject(messageKey) // Ref<string> | undefined
</script>Default Values
// Simple default
const value = inject('message', 'default value')
// Factory function (for expensive defaults)
const value = inject('key', () => new ExpensiveClass(), true)
// ^ treat as factoryApp-Level Provide
Available to all components:
// main.ts
import { createApp } from 'vue'
const app = createApp(App)
app.provide('globalConfig', { theme: 'dark' })Reactive Provide/Inject
Provide reactive values for automatic updates:
<!-- Provider.vue -->
<script setup lang="ts">
import { provide, ref } from 'vue'
const count = ref(0)
provide('count', count)
</script>The injected value maintains reactivity connection.
Mutations Best Practice
Keep mutations in the provider, expose update functions:
<!-- Provider.vue -->
<script setup lang="ts">
import { provide, ref, readonly } from 'vue'
const location = ref('North Pole')
function updateLocation(newLocation: string) {
location.value = newLocation
}
provide('location', {
location: readonly(location), // Prevent direct mutation
updateLocation
})
</script><!-- Injector.vue -->
<script setup lang="ts">
import { inject } from 'vue'
const { location, updateLocation } = inject('location')!
</script>
<template>
<button @click="updateLocation('South Pole')">
{{ location }}
</button>
</template>Using Symbol Keys
Recommended for libraries and large apps to avoid collisions:
// keys.ts
export const myKey = Symbol('myKey')
// provider
provide(myKey, value)
// injector
inject(myKey)Type Helpers
// String key with explicit type
const foo = inject<string>('foo')
// ^? string | undefined
// With default (removes undefined)
const foo = inject<string>('foo', 'default')
// ^? string
// Force non-undefined (use when certain it's provided)
const foo = inject('foo') as string<!-- Source references:
- https://vuejs.org/guide/components/provide-inject.html
- https://vuejs.org/guide/typescript/composition-api.html#typing-provide-inject
-->
Vue Reactivity System
Vue's reactivity system enables automatic tracking of state changes and DOM updates.
ref()
Create reactive primitive values with ref(). Access/modify via .value in JavaScript, auto-unwrapped in templates.
import { ref } from 'vue'
const count = ref(0)
console.log(count.value) // 0
count.value++<script setup lang="ts">
import { ref } from 'vue'
const count = ref(0)
function increment() {
count.value++
}
</script>
<template>
<button @click="increment">{{ count }}</button>
</template>Typing refs
import { ref } from 'vue'
import type { Ref } from 'vue'
// Type inference
const year = ref(2020) // Ref<number>
// Explicit generic
const name = ref<string | null>(null)
// Ref type annotation
const id: Ref<string | number> = ref('abc')reactive()
Create reactive objects. No .value needed, but cannot reassign the entire object.
import { reactive } from 'vue'
interface State {
count: number
name: string
}
const state: State = reactive({
count: 0,
name: 'Vue'
})
state.count++ // reactiveLimitations of reactive()
1. Only works with objects - not primitives 2. Cannot replace entire object - loses reactivity 3. Destructuring loses reactivity - use toRefs() instead
const state = reactive({ count: 0 })
// ❌ Loses reactivity
let { count } = state
// ✅ Use toRefs
import { toRefs } from 'vue'
const { count } = toRefs(state)Recommendation
Use ref() as the primary API for declaring reactive state - it works with any value type and has consistent behavior.
Deep Reactivity
Both ref() and reactive() are deeply reactive by default:
const obj = ref({
nested: { count: 0 },
arr: ['foo', 'bar']
})
// These trigger updates
obj.value.nested.count++
obj.value.arr.push('baz')Use shallowRef() or shallowReactive() to opt out of deep reactivity for performance.
DOM Update Timing
DOM updates are batched and asynchronous. Use nextTick() to wait for updates:
import { ref, nextTick } from 'vue'
const count = ref(0)
async function increment() {
count.value++
await nextTick()
// DOM is now updated
}Ref Unwrapping Rules
- In templates: Top-level refs auto-unwrap
- In reactive objects: Refs auto-unwrap when accessed as properties
- In arrays/collections: Refs do NOT auto-unwrap
const count = ref(0)
const state = reactive({ count })
console.log(state.count) // 0 (unwrapped)
const books = reactive([ref('Vue Guide')])
console.log(books[0].value) // Need .valuecomputed()
Derive values from reactive state with automatic caching. Only re-evaluates when dependencies change.
import { ref, computed } from 'vue'
const firstName = ref('John')
const lastName = ref('Doe')
// Readonly computed
const fullName = computed(() => `${firstName.value} ${lastName.value}`)
// Writable computed
const fullNameWritable = computed({
get() {
return `${firstName.value} ${lastName.value}`
},
set(newValue: string) {
[firstName.value, lastName.value] = newValue.split(' ')
}
})Computed Best Practices
- Getters should be pure - no side effects, no mutating other state
- Don't mutate computed values - mutate the source instead
- Use computed over methods for derived data (caching benefit)
// ✅ Cached - only recalculates when items changes
const activeItems = computed(() => items.value.filter(x => x.active))
// ❌ Not cached - runs on every render
function getActiveItems() {
return items.value.filter(x => x.active)
}watch()
Explicitly watch reactive sources and run side effects when they change. Lazy by default.
import { ref, watch } from 'vue'
const id = ref(1)
watch(id, async (newId, oldId) => {
const data = await fetchData(newId)
// handle data...
})Watch Source Types
const x = ref(0)
const obj = reactive({ count: 0 })
// Single ref
watch(x, (newX) => console.log(newX))
// Getter function
watch(() => obj.count, (count) => console.log(count))
// Multiple sources
watch([x, () => obj.count], ([newX, newCount]) => {
console.log(newX, newCount)
})Watch Options
watch(source, callback, {
immediate: true, // Run immediately on creation
deep: true, // Watch nested properties
once: true, // Trigger only once (3.4+)
flush: 'post' // Run after DOM update
})watchEffect()
Automatically tracks dependencies and runs immediately. Re-runs when any tracked dependency changes.
import { ref, watchEffect } from 'vue'
const todoId = ref(1)
const data = ref(null)
watchEffect(async () => {
const response = await fetch(`/api/todos/${todoId.value}`)
data.value = await response.json()
})watch vs watchEffect
| Feature | watch | watchEffect |
|---|---|---|
| Dependency tracking | Explicit | Automatic |
| Lazy | Yes | No (immediate) |
| Access old value | Yes | No |
| Best for | Specific sources | Multiple dependencies |
Watcher Cleanup (3.5+)
Cancel stale async operations:
import { watch, onWatcherCleanup } from 'vue'
watch(id, async (newId) => {
const controller = new AbortController()
fetch(`/api/${newId}`, { signal: controller.signal })
onWatcherCleanup(() => controller.abort())
})Stopping Watchers
const stop = watch(source, callback)
const stop2 = watchEffect(() => { /* ... */ })
// Stop manually
stop()
stop2()
// Pause/Resume (3.5+)
const { stop, pause, resume } = watchEffect(() => { /* ... */ })<!-- Source references:
- https://vuejs.org/guide/essentials/reactivity-fundamentals.html
- https://vuejs.org/guide/essentials/computed.html
- https://vuejs.org/guide/essentials/watchers.html
- https://vuejs.org/api/reactivity-core.html
-->
Vue Router Typing
Type-safe routing patterns for Vue Router.
For Nuxt: Use file-based routing instead. See nuxt skill for Nuxt routing patterns.Route Meta Types
Extend RouteMeta for typed route.meta:
// router/types.ts
import 'vue-router'
declare module 'vue-router' {
interface RouteMeta {
requiresAuth?: boolean
title?: string
roles?: ('admin' | 'user')[]
}
}Usage:
const route = useRoute()
route.meta.requiresAuth // boolean | undefined (typed!)
route.meta.title // string | undefinedTyped Route Params with unplugin-vue-router
Use unplugin-vue-router for fully typed routes:
pnpm add -D unplugin-vue-router// vite.config.ts
import VueRouter from 'unplugin-vue-router/vite'
export default defineConfig({
plugins: [VueRouter(), Vue()], // VueRouter BEFORE Vue
})Typed useRoute:
// Auto-generated route types from file structure
const route = useRoute('/users/[id]')
route.params.id // string (typed!)
const route = useRoute('/posts/[...slug]')
route.params.slug // string[] (typed!)Typed router.push:
const router = useRouter()
// ✅ Type-checked
router.push({ name: '/users/[id]', params: { id: '123' } })
// ❌ TypeScript error - wrong param
router.push({ name: '/users/[id]', params: { userId: '123' } })Scroll Behavior Types
Type scroll behavior function:
import type { RouterScrollBehavior } from 'vue-router'
const scrollBehavior: RouterScrollBehavior = (to, from, savedPosition) => {
if (savedPosition) return savedPosition
if (to.hash) return { el: to.hash, behavior: 'smooth' }
return { top: 0 }
}
const router = createRouter({
history: createWebHistory(),
routes,
scrollBehavior,
})Dynamic Route Params
Handle union types for dynamic segments:
// routes/[type].vue where type can be 'posts' | 'users' | 'comments'
const route = useRoute()
// Narrow params type
type ContentType = 'posts' | 'users' | 'comments'
const type = route.params.type as ContentType
// Or use route guards
if (route.params.type === 'posts') {
// TypeScript knows type is 'posts'
}Navigation Guards Types
Type navigation guards:
import type { NavigationGuardWithThis, RouteLocationNormalized } from 'vue-router'
const authGuard: NavigationGuardWithThis<undefined> = (to, from) => {
if (to.meta.requiresAuth && !isAuthenticated()) {
return { name: 'login', query: { redirect: to.fullPath } }
}
}
router.beforeEach(authGuard)Per-route guards:
const routes = [
{
path: '/admin',
component: AdminPage,
beforeEnter: (to: RouteLocationNormalized) => {
if (!hasAdminRole()) return { name: 'forbidden' }
},
},
]RouteLocation Types
Common route types:
import type {
RouteLocationNormalized, // Resolved route (after navigation)
RouteLocationNormalizedLoaded, // Current route (from useRoute)
RouteLocationRaw, // Input to router.push()
RouteRecordRaw, // Route config definition
} from 'vue-router'Common Mistakes
Not extending RouteMeta module:
// ❌ Loses type info
route.meta.customField // any
// ✅ Extend the interface
declare module 'vue-router' {
interface RouteMeta { customField: string }
}Assuming params are always strings:
// Catch-all routes have string[] params
const route = useRoute()
// ❌ May be string[]
const id = route.params.id
// ✅ Handle both cases
const id = Array.isArray(route.params.id)
? route.params.id[0]
: route.params.idVue Testing
Test patterns for Vue 3 components, composables, and utilities.
Quick Reference
| Test Type | Pattern |
|---|---|
| Component | mount(Component, { props, slots }) |
| User interaction | await wrapper.trigger('click') |
| Emitted events | wrapper.emitted('update') |
| Composable | Call directly, test return values |
| Utils | Pure function testing (easiest) |
Stack
- Vitest - test runner
- @vue/test-utils - component mounting, interaction
- @testing-library/vue - user-centric alternative
- happy-dom / jsdom - DOM environment
File Location
Colocate tests with code:
Button.vue → Button.spec.ts
useAuth.ts → useAuth.spec.ts
formatters.ts → formatters.spec.tsComponent Tests
Basic
import { mount } from '@vue/test-utils'
import Button from './Button.vue'
it('renders slot', () => {
const wrapper = mount(Button, {
slots: { default: 'Click me' }
})
expect(wrapper.text()).toBe('Click me')
})
it('emits on click', async () => {
const wrapper = mount(Button)
await wrapper.trigger('click')
expect(wrapper.emitted('click')).toHaveLength(1)
})Props
it('applies variant class', () => {
const wrapper = mount(Button, {
props: { variant: 'primary' }
})
expect(wrapper.classes()).toContain('btn-primary')
})Emits
it('emits update with payload', async () => {
const wrapper = mount(Input)
await wrapper.find('input').setValue('new value')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['new value'])
})Slots
it('renders named slots', () => {
const wrapper = mount(Card, {
slots: {
header: '<h1>Title</h1>',
default: '<p>Content</p>'
}
})
expect(wrapper.html()).toContain('<h1>Title</h1>')
})Composable Tests
Call directly, no mounting needed:
import { useCounter } from './useCounter'
it('increments count', () => {
const { count, increment } = useCounter(0)
expect(count.value).toBe(0)
increment()
expect(count.value).toBe(1)
})
it('resets to initial', () => {
const { count, increment, reset } = useCounter(5)
increment()
increment()
expect(count.value).toBe(7)
reset()
expect(count.value).toBe(5)
})Utils Tests
Easiest - pure functions:
import { formatCurrency, slugify } from './formatters'
describe('formatCurrency', () => {
it('formats USD', () => {
expect(formatCurrency(10.5)).toBe('$10.50')
})
})
describe('slugify', () => {
it('converts to lowercase', () => {
expect(slugify('Hello World')).toBe('hello-world')
})
it('removes special chars', () => {
expect(slugify('Hello! World?')).toBe('hello-world')
})
})Mocking
Composables:
import { vi } from 'vitest'
vi.mock('./useAuth', () => ({
useAuth: vi.fn(() => ({
user: { id: 1, name: 'Test' },
isAuthenticated: true
}))
}))API calls:
global.fetch = vi.fn(() =>
Promise.resolve({
json: () => Promise.resolve({ data: [] })
})
)Router Mocking
Mock useRoute and useRouter for component tests:
import { vi } from 'vitest'
import { mount } from '@vue/test-utils'
vi.mock('vue-router', () => ({
useRoute: vi.fn(() => ({
params: { id: '123' },
query: { filter: 'active' },
path: '/users/123',
})),
useRouter: vi.fn(() => ({
push: vi.fn(),
replace: vi.fn(),
})),
}))
it('uses route params', () => {
const wrapper = mount(UserPage)
expect(wrapper.text()).toContain('123')
})Dynamic route mocking per test:
import { useRoute } from 'vue-router'
it('handles different routes', () => {
vi.mocked(useRoute).mockReturnValue({
params: { id: '456' },
} as any)
const wrapper = mount(UserPage)
expect(wrapper.text()).toContain('456')
})Suspense and Teleport
Testing async components with Suspense:
import { flushPromises, mount } from '@vue/test-utils'
it('renders async content', async () => {
const wrapper = mount(AsyncComponent, {
global: {
stubs: { Suspense: false }, // Don't stub Suspense
},
})
// Wait for async setup to complete
await flushPromises()
expect(wrapper.text()).toContain('Loaded content')
})Testing Teleport:
it('teleports modal content', () => {
const wrapper = mount(Modal, {
global: {
stubs: {
teleport: true, // Stub teleport to render inline
},
},
})
expect(wrapper.text()).toContain('Modal content')
})Access teleported content:
it('finds teleported content', () => {
document.body.innerHTML = '<div id="modal-target"></div>'
mount(Modal, { props: { open: true } })
// Content teleports to #modal-target
expect(document.body.innerHTML).toContain('Modal content')
})Best Practices
Do:
- Test behavior (what user sees/does), not implementation
- Arrange-Act-Assert structure
- One assertion per test
- Descriptive test names
- Mock external dependencies
Don't:
- Test Vue internals (reactivity)
- Test third-party libraries
- Test trivial getters/setters
- Test implementation details
What to Test
Test:
- User interactions (clicks, inputs)
- Conditional rendering
- Props validation, emitted events
- Computed values, business logic
Skip:
- Vue internals, third-party libs
- Trivial getters/setters
- Implementation details
Running
pnpm test # all
pnpm exec vitest Button.spec.ts # specific
pnpm exec vitest --watch # watch
pnpm test --coverage # coverageDocs: vitest.dev · test-utils.vuejs.org
Vue TypeScript Patterns
TypeScript-specific patterns for Vue 3 development.
Provide/Inject Types
Use InjectionKey for type-safe dependency injection:
import type { InjectionKey } from 'vue'
import type { User } from './types'
// Define typed key
export const UserKey: InjectionKey<User> = Symbol('user')
// Provider component
const user = ref<User>({ id: 1, name: 'John' })
provide(UserKey, user)
// Consumer component
const user = inject(UserKey) // Ref<User> | undefined
const user = inject(UserKey)! // Ref<User> (assert non-null)With default value:
const user = inject(UserKey, ref({ id: 0, name: 'Guest' }))
// Type: Ref<User> (no undefined)vue-tsc Strict Templates
Enable stricter template type checking:
# Check templates with strict mode
vue-tsc --noEmit --strict-templatesCatches template errors like:
- Accessing non-existent properties
- Wrong prop types
- Missing required props
tsconfig Settings
Required for Vue 3:
{
"compilerOptions": {
"moduleResolution": "bundler",
"verbatimModuleSyntax": true,
"strict": true,
"jsx": "preserve"
}
}`moduleResolution: "bundler"` - Matches Vite/webpack resolution. Avoids .js extension issues.
`verbatimModuleSyntax: true` - Enforces explicit type imports:
// ❌ May cause issues with bundlers
import { User } from './types'
// ✅ Explicit type import
import type { User } from './types'Component Type Helpers
Extract props type from component:
import type { ComponentProps, ComponentSlots, ComponentEmits } from 'vue-component-type-helpers'
import MyComponent from './MyComponent.vue'
type Props = ComponentProps<typeof MyComponent>
type Slots = ComponentSlots<typeof MyComponent>
type Emits = ComponentEmits<typeof MyComponent>Extract exposed methods:
import type { ComponentExposed } from 'vue-component-type-helpers'
type Exposed = ComponentExposed<typeof MyComponent>Generic Components
Define generic components with typed slots:
<script setup lang="ts" generic="T extends { id: string }">
defineProps<{
items: T[]
}>()
defineSlots<{
default: (props: { item: T }) => any
}>()
</script>
<template>
<div v-for="item in items" :key="item.id">
<slot :item="item" />
</div>
</template>Ref Type Narrowing
Handle ref type narrowing correctly:
const maybeUser = ref<User | null>(null)
// ❌ TypeScript still sees User | null
if (maybeUser.value) {
maybeUser.value.name // Error: possibly null
}
// ✅ Use computed or extract value
const userName = computed(() => maybeUser.value?.name ?? 'Guest')
// ✅ Or guard in same expression
maybeUser.value && maybeUser.value.nameEvent Handler Types
Type event handlers correctly:
// DOM events
const onClick = (e: MouseEvent) => { ... }
const onInput = (e: Event) => {
const target = e.target as HTMLInputElement
console.log(target.value)
}
// Component emits
const onUpdate = (value: string) => { ... }Common Mistakes
Forgetting to import types explicitly:
// ❌ Runtime import of type-only
import { User } from './types'
// ✅ Type-only import
import type { User } from './types'Not using `as const` for literal types:
// ❌ Type is string[]
const variants = ['primary', 'secondary']
// ✅ Type is readonly ['primary', 'secondary']
const variants = ['primary', 'secondary'] as constClient Utilities
Pure functions for formatting, validation, transformation, and parsing.
Quick Reference
| Category | Examples |
|---|---|
| Formatters | formatCurrency, formatDate, formatBytes |
| Validators | isValidEmail, isValidUrl, isValidPhone |
| Transformers | slugify, truncate, capitalize |
| Parsers | parseQuery, parseJSON, parseDate |
Rules
Pure functions:
- Same input → same output
- No side effects
- No external state mutation
- No API calls, no refs, no reactive
When NOT to use utils:
- Stateful logic → use composables
- Vue-specific → use composables
- Component logic → keep in component
- API calls → use queries
Structure
// utils/formatters.ts
export function formatCurrency(amount: number, currency = 'USD'): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
}).format(amount)
}
export function formatRelativeTime(date: Date): string {
const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' })
const diff = date.getTime() - Date.now()
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
return rtf.format(days, 'day')
}Naming: Descriptive verbs (formatCurrency, validateEmail, parseQuery) Organization: Group by category (formatters.ts, validators.ts) Exports: Named exports only
Examples by Category
Formatters:
// utils/formatters.ts
export function formatBytes(bytes: number): string { ... }
export function formatPhone(phone: string): string { ... }Validators:
// utils/validators.ts
export function isValidEmail(email: string): boolean {
return /^[^\s@]+@[^\s@][^\s.@]*\.[^\s@]+$/.test(email)
}
export function isValidUrl(url: string): boolean {
try { new URL(url); return true }
catch { return false }
}Transformers:
// utils/transformers.ts
export function slugify(text: string): string {
return text.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-')
}
export function truncate(text: string, length: number): string {
return text.length > length ? `${text.slice(0, length)}...` : text
}Parsers:
// utils/parsers.ts
export function parseQuery(search: string): Record<string, string> {
return Object.fromEntries(new URLSearchParams(search))
}
export function parseJSON<T>(json: string, fallback: T): T {
try { return JSON.parse(json) }
catch { return fallback }
}Common Mistakes
Side effects (not pure):
// ❌ Wrong - mutates external state
let count = 0
export function increment() {
count++
return count
}
// ✅ Correct - pure
export function add(a: number, b: number): number {
return a + b
}Using utils for stateful logic:
// ❌ Wrong - should be composable
export function useCounter() { ... }
// ✅ Correct - pure transformation
export function formatCount(count: number): string { ... }Organization
Flat for small projects:
utils/
├── formatters.ts
├── validators.ts
└── transformers.tsNested for large projects:
utils/
├── formatters/
│ ├── date.ts
│ ├── currency.ts
│ └── index.ts
└── validators/
├── email.ts
└── index.tsRelated skills
How it compares
Pick vue over generic frontend skills when the stack is Vue 3 or Nuxt and components must follow script setup idioms.
FAQ
What does vue do?
Use when editing .vue files, creating Vue 3 components, writing composables, or testing Vue code - provides Composition API patterns, props/emits best practices, VueUse integration, and reactive destructuring guidance
When should I invoke vue?
Use when editing .vue files, creating Vue 3 components, writing composables, or testing Vue code - provides Composition API patterns, props/emits best practices, VueUse integration, and reactive destructuring guidance
Where is the source documentation?
Ground claims in SKILL.md excerpts and linked reference files from the cached docs.
Is Vue safe to install?
skills.sh reports 3 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.