
Vue Best Practices
- 140 installs
- 5 repo stars
- Updated June 25, 2026
- ejirocodes/agent-skills
For integrating A developer tool for AI integration and automation
About
A developer tool for AI integration and automation. This is a developer tool for building and integrating AI-powered features.
- AI
- Developer tool
Vue Best Practices by the numbers
- 140 all-time installs (skills.sh)
- Ranked #3,485 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Jul 24, 2026 (Skillselion catalog sync)
npx skills add https://github.com/ejirocodes/agent-skills --skill vue-best-practicesAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 140 |
|---|---|
| repo stars | ★ 5 |
| Last updated | June 25, 2026 |
| Repository | ejirocodes/agent-skills ↗ |
What it does
For integrating A developer tool for AI integration and automation
Files
Vue 3 Best Practices
Quick Reference
| Topic | When to Use | Reference |
|---|---|---|
| TypeScript | Props extraction, generic components, useTemplateRef, JSDoc, reactive props destructure | typescript.md |
| Volar | IDE config, strictTemplates, CSS modules, directive comments, Volar 3.0 migration | volar.md |
| Components | defineModel, deep watch, onWatcherCleanup, useId, deferred teleport | components.md |
| Tooling | moduleResolution, HMR SSR, duplicate plugin detection | tooling.md |
| Testing | Pinia store mocking, setup stores, Vue Router typed params | testing.md |
Essential Patterns
Extract Component Props
import type { ComponentProps } from 'vue-component-type-helpers'
import MyButton from './MyButton.vue'
type Props = ComponentProps<typeof MyButton>Reactive Props Destructure (Vue 3.5+)
<script setup lang="ts">
// Destructured props are reactive - preferred in Vue 3.5+
const { name, count = 0 } = defineProps<{ name: string; count?: number }>()
</script>useTemplateRef (Vue 3.5+)
<script setup lang="ts">
import { useTemplateRef, onMounted } from 'vue'
const inputRef = useTemplateRef('input') // Auto-typed
onMounted(() => inputRef.value?.focus())
</script>
<template><input ref="input" /></template>onWatcherCleanup (Vue 3.5+)
import { watch, onWatcherCleanup } from 'vue'
watch(query, async (q) => {
const controller = new AbortController()
onWatcherCleanup(() => controller.abort())
await fetch(`/api?q=${q}`, { signal: controller.signal })
})defineModel with Required
// Returns Ref<Item> instead of Ref<Item | undefined>
const model = defineModel<Item>({ required: true })Deep Watch with Numeric Depth
// Vue 3.5+ - watch array mutations without full traversal
watch(items, handler, { deep: 1 })Pinia Store Test Setup
import { createTestingPinia } from '@pinia/testing'
import { vi } from 'vitest'
mount(Component, {
global: {
plugins: [createTestingPinia({ createSpy: vi.fn })]
}
})Common Mistakes
1. Using `InstanceType<typeof Component>['$props']` - Use ComponentProps instead 2. Missing `createSpy` in createTestingPinia - Required in @pinia/testing 1.0+ 3. Using `withDefaults` with union types - Use Reactive Props Destructure 4. `strictTemplates` in wrong tsconfig - Add to tsconfig.app.json, not root 5. ts_ls with Volar 3.0 - Use vtsls instead (Neovim) 6. `deep: true` on large structures - Use numeric depth for performance 7. Watching destructured props directly - Wrap in getter: watch(() => count, ...) 8. Random IDs in SSR - Use useId() for hydration-safe IDs
Component Patterns
Table of Contents
---
defineModel Patterns
Components using defineModel may fire the @update:model-value event with undefined in certain edge cases. TypeScript types don't always reflect this behavior.
Version Note: This issue may be resolved in Vue 3.5+. Testing with Vue 3.5.26 could not reproduce the double emission with undefined. Verify the issue exists in your specific scenario before applying workarounds.Symptoms
- Parent component receives
undefinedunexpectedly - Runtime error: "Cannot read property of undefined"
- Type mismatch between expected
Tand receivedT | undefined - Issue appears when clearing/resetting the model value
Root Cause
defineModel returns Ref<T | undefined> by default, even when T is non-nullable. The update event can fire with undefined when:
- Component unmounts
- Model is explicitly cleared
- Internal state resets
Fix
Option 1: Use required option (Vue 3.5+) - Recommended
// Returns Ref<Item> instead of Ref<Item | undefined>
const model = defineModel<Item>({ required: true })Option 2: Type parent handler to accept undefined
<template>
<MyComponent
v-model="item"
@update:model-value="handleUpdate"
/>
</template>
<script setup lang="ts">
// Handle both value and undefined
const handleUpdate = (value: Item | undefined) => {
if (value !== undefined) {
item.value = value
}
}
</script>Option 3: Use default value in defineModel
const model = defineModel<string>({ default: '' })Multiple v-models
<script setup lang="ts">
// Named models
const firstName = defineModel<string>('firstName', { required: true })
const lastName = defineModel<string>('lastName', { required: true })
// Default model
const selected = defineModel<boolean>({ default: false })
</script>Type Declaration Pattern
// In child component
interface Props {
modelValue: Item
}
const model = defineModel<Item>({ required: true })
// Emits will be typed as (value: Item) not (value: Item | undefined)Reference: vuejs/core#12817
---
Deep Watch with Numeric Depth
Vue 3.5 introduced deep: number for watch depth control. This allows watching array mutations without the performance cost of deep traversal.
Symptoms
- Array mutations not triggering watch callback
- Deep watch causing performance issues on large nested objects
The Feature
// Vue 3.5+ only
watch(items, (newVal) => {
// Triggered on array mutations (push, pop, splice, etc.)
}, { deep: 1 })| deep value | Behavior |
|---|---|
true | Full recursive traversal (original behavior) |
false | Only reference changes |
1 | One level deep - array mutations, not nested objects |
2 | Two levels deep |
n | N levels deep |
Fix
Step 1: Ensure Vue 3.5+
npm install vue@^3.5.0Step 2: Use numeric depth
import { watch, ref } from 'vue'
const items = ref([{ id: 1, data: { nested: 'value' } }])
// Watch array mutations only (push, pop, etc.)
watch(items, (newItems) => {
console.log('Array mutated')
}, { deep: 1 })
// Won't trigger on: items.value[0].data.nested = 'new'
// Will trigger on: items.value.push(newItem)Performance Comparison
const largeNestedData = ref({ /* deeply nested structure */ })
// SLOW - traverses entire structure
watch(largeNestedData, handler, { deep: true })
// FAST - only watches top-level changes
watch(largeNestedData, handler, { deep: 1 })
// FASTEST - only reference changes
watch(largeNestedData, handler, { deep: false })Alternative: watchEffect for Selective Tracking
// Only tracks properties actually accessed
watchEffect(() => {
// Only re-runs when items.value.length or first item changes
console.log(items.value.length, items.value[0]?.id)
})TypeScript Note
If TypeScript complains about numeric deep, ensure: 1. Vue version is 3.5+ 2. TypeScript version is current (types are included with vue package) 3. tsconfig targets correct node_modules types
Reference: Vue 3.5 Release Notes
---
onWatcherCleanup
Vue 3.5 introduced onWatcherCleanup() for registering cleanup callbacks in watchers. This simplifies managing side effects like aborting requests, clearing timers, or cleaning up subscriptions.
Basic Usage
import { watch, onWatcherCleanup } from 'vue'
watch(searchQuery, async (query) => {
const controller = new AbortController()
// Register cleanup - runs when watcher re-runs or component unmounts
onWatcherCleanup(() => {
controller.abort()
})
const response = await fetch(`/api/search?q=${query}`, {
signal: controller.signal
})
results.value = await response.json()
})In Nested Functions
Unlike the onCleanup parameter, onWatcherCleanup can be used in nested functions:
watch(id, (newId) => {
startPolling(newId)
})
function startPolling(id: string) {
const interval = setInterval(() => {
fetchData(id)
}, 5000)
// Works even though it's not directly in the watch callback
onWatcherCleanup(() => {
clearInterval(interval)
})
}With watchEffect
import { watchEffect, onWatcherCleanup } from 'vue'
watchEffect(() => {
const connection = createWebSocket(url.value)
onWatcherCleanup(() => {
connection.close()
})
})Comparison with onCleanup Parameter
// Old way (still valid)
watch(source, async (value, oldValue, onCleanup) => {
const controller = new AbortController()
onCleanup(() => controller.abort())
// ...
})
// New way (Vue 3.5+)
watch(source, async (value) => {
const controller = new AbortController()
onWatcherCleanup(() => controller.abort())
// ...
})Suppress Warnings
If called outside an active watcher, it logs a warning. Suppress with second parameter:
// Suppress "onWatcherCleanup called outside of watcher" warning
onWatcherCleanup(() => cleanup(), true)Reference: Vue 3.5 Release Notes
---
useId for SSR
Vue 3.5 introduces useId() to generate unique, SSR-stable IDs for accessibility attributes and form elements.
Basic Usage
<script setup>
import { useId } from 'vue'
const inputId = useId()
</script>
<template>
<label :for="inputId">Email</label>
<input :id="inputId" type="email" />
</template>Why useId?
- SSR-safe: IDs are stable across server and client renders, preventing hydration mismatches
- Unique per component instance: Multiple instances get different IDs
- Accessible: Perfect for
aria-labelledby,aria-describedby, form labels
Multiple IDs
<script setup>
import { useId } from 'vue'
const nameId = useId()
const emailId = useId()
const errorId = useId()
</script>
<template>
<div>
<label :for="nameId">Name</label>
<input :id="nameId" :aria-describedby="errorId" />
<span :id="errorId" class="error">Required</span>
</div>
</template>Compared to Random IDs
// ❌ Causes hydration mismatch in SSR
const id = `input-${Math.random().toString(36).slice(2)}`
// ✅ Stable across server/client
const id = useId()Reference: Vue 3.5 Release Notes
---
Deferred Teleport
Vue 3.5 introduces the defer prop for <Teleport> which mounts content after the current render cycle, solving timing issues with dynamic targets.
Problem
<!-- ❌ Target doesn't exist yet during first render -->
<Teleport to="#modal-container">
<Modal />
</Teleport>
<div id="modal-container"></div>Solution
<!-- ✅ Defers teleport until after current render cycle -->
<Teleport to="#modal-container" defer>
<Modal />
</Teleport>
<div id="modal-container"></div>Use Cases
- Teleporting to elements rendered later in the same component
- Dynamic portals that may not exist immediately
- Nested component structures where target is created by a child
Important Notes
deferis opt-in for backwards compatibility- The teleported content renders in the next tick
- Target element must exist by the time deferred teleport executes
Reference: Vue 3.5 Release Notes
Testing Patterns
Table of Contents
- Pinia Store Mocking
- Testing Setup Stores
- Vue Router Typed Params
- Automatic Route Typing with Volar Plugin
---
Pinia Store Mocking
createTestingPinia creates a Pinia instance designed for unit tests that automatically mocks stores.
Important (@pinia/testing 1.0+): ThecreateSpyoption is REQUIRED whenglobals: trueis not set in Vitest config. Omitting it throws: "You must configure thecreateSpyoption."
Symptoms
- "injection Symbol(pinia) not found" error
- "You must configure the
createSpyoption" error - Actions not properly mocked
- Store state not reset between tests
Basic Setup
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import { vi } from 'vitest'
import MyComponent from './MyComponent.vue'
import { useCounterStore } from '@/stores/counter'
test('component uses store', async () => {
const wrapper = mount(MyComponent, {
global: {
plugins: [
createTestingPinia({
createSpy: vi.fn, // REQUIRED without globals: true
initialState: {
counter: { count: 10 } // Set initial state
}
})
]
}
})
// Get store instance AFTER mounting
const store = useCounterStore()
// Actions are automatically stubbed
await wrapper.find('button').trigger('click')
expect(store.increment).toHaveBeenCalled()
})With globals: true in Vitest Config
If you have globals: true in your Vitest config, createSpy is auto-detected:
// vitest.config.ts
export default defineConfig({
test: {
globals: true // Enables auto-detection of vi.fn
}
})
// Test file - createSpy not needed
const pinia = createTestingPinia({
initialState: { counter: { count: 5 } }
})Custom Action Behavior
test('component handles async action', async () => {
const wrapper = mount(MyComponent, {
global: {
plugins: [
createTestingPinia({
createSpy: vi.fn,
stubActions: false // Don't stub, use real actions
})
]
}
})
const store = useCounterStore()
// Override specific action with mock implementation
store.fetchData = vi.fn().mockResolvedValue({ items: [] })
await wrapper.find('.load-button').trigger('click')
expect(store.fetchData).toHaveBeenCalled()
})Using vi.spyOn for More Control
test('action with custom mock', async () => {
const wrapper = mount(MyComponent, {
global: {
plugins: [createTestingPinia({ createSpy: vi.fn })]
}
})
const store = useCounterStore()
// More control with spyOn
const spy = vi.spyOn(store, 'increment')
spy.mockImplementation(() => {
store.count += 10 // Custom increment
})
await wrapper.find('button').trigger('click')
expect(store.count).toBe(10)
})Reset Between Tests
describe('Store Tests', () => {
let pinia: ReturnType<typeof createTestingPinia>
beforeEach(() => {
pinia = createTestingPinia({
createSpy: vi.fn
})
})
afterEach(() => {
vi.clearAllMocks()
})
test('test 1', () => { /* fresh pinia instance */ })
test('test 2', () => { /* fresh pinia instance */ })
})Including Pinia Plugins
import { myPiniaPlugin } from '@/plugins/pinia'
const pinia = createTestingPinia({
createSpy: vi.fn,
plugins: [myPiniaPlugin] // Pass plugins here, not with .use()
})Reference: Pinia Testing Guide
---
Testing Setup Stores
Setup stores (function-based) have special testing considerations because they don't have an options.actions object.
Direct Store Testing
import { setActivePinia, createPinia } from 'pinia'
import { useCounterStore } from '@/stores/counter'
describe('Counter Store', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
test('increments count', () => {
const store = useCounterStore()
expect(store.count).toBe(0)
store.increment()
expect(store.count).toBe(1)
})
test('computed values', () => {
const store = useCounterStore()
store.count = 5
expect(store.doubleCount).toBe(10)
})
})Setup Store Definition
// stores/counter.ts
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const doubleCount = computed(() => count.value * 2)
function increment() {
count.value++
}
async function fetchCount() {
const response = await fetch('/api/count')
count.value = await response.json()
}
return { count, doubleCount, increment, fetchCount }
})Component Testing with Setup Store
test('setup store in component', async () => {
const pinia = createTestingPinia({
createSpy: vi.fn,
initialState: {
counter: { count: 5 }
}
})
const wrapper = mount(MyComponent, {
global: { plugins: [pinia] }
})
const store = useCounterStore()
expect(store.count).toBe(5)
expect(store.doubleCount).toBe(10)
// Action is stubbed by default
await wrapper.find('button').trigger('click')
expect(store.increment).toHaveBeenCalled()
})Testing $subscribe
test('store subscription', async () => {
setActivePinia(createPinia())
const store = useCounterStore()
const callback = vi.fn()
store.$subscribe(callback)
store.increment()
expect(callback).toHaveBeenCalledWith(
expect.objectContaining({ storeId: 'counter' }),
expect.objectContaining({ count: 1 })
)
})Reference: Pinia Testing Guide
---
Vue Router Typed Params
With unplugin-vue-router, route.params becomes a union of ALL page param types. TypeScript cannot narrow this properly without help.
Symptoms
- "Property 'id' does not exist on type 'RouteParams'"
route.params.idshows asstring | undefinedeverywhere- Union type of all route params instead of specific route
- Type narrowing with
if (route.name === 'users-id')doesn't work
Root Cause
unplugin-vue-router generates a union type of all possible route params. TypeScript's control flow analysis can't narrow this union based on route name checks.
Fix
Option 1: Pass route path to useRoute (recommended)
// pages/users/[id].vue
import { useRoute } from 'vue-router/auto'
// Specify the route path for proper typing
const route = useRoute('/users/[id]')
// Now properly typed as { id: string }
console.log(route.params.id) // string, not string | undefinedOption 2: Type assertion with specific route
import { useRoute } from 'vue-router'
import type { RouteLocationNormalized } from 'vue-router/auto-routes'
const route = useRoute() as RouteLocationNormalized<'/users/[id]'>
route.params.id // Properly typedOption 3: Define route-specific param type
// In your page component
interface UserRouteParams {
id: string
}
const route = useRoute()
const { id } = route.params as UserRouteParamsRoute Path Format
The route path matches the file path pattern:
| File Path | Route Path |
|---|---|
pages/users/[id].vue | /users/[id] |
pages/posts/[slug]/comments.vue | /posts/[slug]/comments |
pages/[...path].vue | /[...path] |
Required tsconfig Settings
{
"compilerOptions": {
"moduleResolution": "bundler"
}
}Reference: unplugin-vue-router TypeScript docs
---
Automatic Route Typing with Volar Plugin
The sfc-typed-router Volar plugin automatically types useRoute() and $route in page components, eliminating the need for manual path specification.
Setup
Add to your tsconfig.json:
{
"compilerOptions": {
"rootDir": "."
},
"vueCompilerOptions": {
"plugins": ["unplugin-vue-router/volar/sfc-typed-router"]
}
}How It Works
With the plugin enabled, useRoute() in a page component automatically uses the correct route type:
<!-- pages/users/[id].vue -->
<script setup lang="ts">
// No path needed - automatically typed based on file location
const route = useRoute()
// route.params.id is properly typed as string
console.log(route.params.id)
</script>Benefits
- No need to write
useRoute('/users/[id]')in every page $routein templates is also typed correctly- Works with both Composition API and Options API
- Autocomplete for route params
Limitations
- Only works in page components (files under
pages/) - Non-page components still need explicit typing
- Requires Volar and correct tsconfig setup
Reference: unplugin-vue-router Volar Plugin
Tooling Configuration
Table of Contents
---
moduleResolution Bundler Migration
Recent versions of @vue/tsconfig changed moduleResolution from "node" to "bundler". This can break existing projects.
Symptoms
Cannot find module 'vue'or other packagesOption '--resolveJsonModule' cannot be specified without 'node' module resolution- Errors appear after updating
@vue/tsconfig - Some third-party packages no longer resolve
Root Cause
moduleResolution: "bundler" requires: 1. TypeScript 5.0+ 2. Packages to have proper exports field in package.json 3. Different resolution rules than Node.js classic resolution
Fix
Option 1: Ensure TypeScript 5.0+ everywhere
npm install -D typescript@^5.0.0In monorepos, ALL packages must use TypeScript 5.0+.
Option 2: Add compatibility workaround
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler",
"resolvePackageJsonExports": false
}
}Setting resolvePackageJsonExports: false restores compatibility with packages that don't have proper exports.
Option 3: Revert to Node resolution
{
"compilerOptions": {
"moduleResolution": "node"
}
}Which Packages Break?
Packages break if they:
- Lack
exportsfield in package.json - Have incorrect
exportsconfiguration - Rely on Node.js-specific resolution behavior
Diagnosis
# Check which resolution is being used
cat tsconfig.json | grep moduleResolution
# Test if a specific module resolves
npx tsc --traceResolution 2>&1 | grep "module-name"Reference: vuejs/tsconfig#8
---
HMR Debugging for SSR
Hot Module Replacement breaks when modifying Vue component <script setup> sections in SSR applications.
Symptoms
- HMR works for
<template>changes but breaks for<script setup> - "Cannot read property of undefined" after saving
- Full page reload required after script changes
- HMR works in dev:client but not dev:ssr
Root Cause
SSR mode has a different transformation pipeline. The Vue plugin's HMR boundary detection doesn't handle SSR modules the same way as client modules.
Fix
Step 1: Ensure correct SSR plugin configuration
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
ssr: {
// Don't externalize these for HMR to work
noExternal: ['vue', '@vue/runtime-core', '@vue/runtime-dom']
}
})Step 2: Configure dev server for SSR HMR
// server.ts
import { createServer } from 'vite'
const vite = await createServer({
server: { middlewareMode: true },
appType: 'custom'
})
// Use vite.ssrLoadModule for server-side imports
const { render } = await vite.ssrLoadModule('/src/entry-server.ts')
// Handle HMR
vite.watcher.on('change', async (file) => {
if (file.endsWith('.vue')) {
// Invalidate the module
const mod = vite.moduleGraph.getModuleById(file)
if (mod) {
vite.moduleGraph.invalidateModule(mod)
}
}
})Step 3: Add HMR acceptance in entry-server
// entry-server.ts
import { createApp } from './main'
export async function render(url: string) {
const app = createApp()
// ... render logic
}
// Accept HMR updates
if (import.meta.hot) {
import.meta.hot.accept()
}Framework-Specific Solutions
Nuxt 3
HMR should work out of the box. If not:
rm -rf .nuxt node_modules/.vite
npm install
npm run devVite SSR Template
Ensure you're using the latest @vitejs/plugin-vue:
npm install @vitejs/plugin-vue@latestDebugging
Enable verbose HMR logging:
// vite.config.ts
export default defineConfig({
server: {
hmr: {
overlay: true
}
},
logLevel: 'info' // Shows HMR updates
})Known Limitations
- HMR for
<script>(not<script setup>) may require full reload - SSR components with external dependencies may not hot-reload
- State is not preserved for SSR components (expected behavior)
Reference: vite-plugin-vue#525
---
Duplicate Plugin Detection
When using Vite's JavaScript API, if the Vue plugin is loaded in vite.config.js and specified again in inlineConfig, it gets registered twice, causing cryptic build errors.
Symptoms
- Build produces unexpected output or fails silently
- "Cannot read property of undefined" during build
- Different build behavior between CLI and JavaScript API
- Vue components render incorrectly after build
Root Cause
Vite doesn't deduplicate plugins by name when merging configs. The Vue plugin's internal state gets corrupted when registered twice.
Fix
Option 1: Use configFile: false with inline plugins
import { build } from 'vite'
import vue from '@vitejs/plugin-vue'
await build({
configFile: false, // Don't load vite.config.js
plugins: [vue()],
// ... rest of config
})Option 2: Don't specify plugins in inlineConfig
// vite.config.js already has vue plugin
import { build } from 'vite'
await build({
// Don't add vue plugin here - it's in vite.config.js
root: './src',
build: { outDir: '../dist' }
})Option 3: Filter out Vue plugin before merging
import { build, loadConfigFromFile } from 'vite'
import vue from '@vitejs/plugin-vue'
const { config } = await loadConfigFromFile({ command: 'build', mode: 'production' })
// Remove existing Vue plugin
const filteredPlugins = config.plugins?.filter(
p => !p || (Array.isArray(p) ? false : p.name !== 'vite:vue')
) || []
await build({
...config,
plugins: [...filteredPlugins, vue({ /* your options */ })]
})Detection Script
Add this to debug plugin registration:
// vite.config.ts
export default defineConfig({
plugins: [
vue(),
{
name: 'debug-plugins',
configResolved(config) {
const vuePlugins = config.plugins.filter(p => p.name?.includes('vue'))
if (vuePlugins.length > 1) {
console.warn('WARNING: Multiple Vue plugins detected:', vuePlugins.map(p => p.name))
}
}
}
]
})Common Scenarios
| Scenario | Solution |
|---|---|
Using vite.createServer() | Use configFile: false |
| Build script with custom config | Don't duplicate plugins |
| Monorepo with shared config | Check for plugin inheritance |
Reference: Vite Issue #5335
TypeScript Patterns
Table of Contents
- Extract Component Props
- Generic Components
- useTemplateRef Typing
- JSDoc for Script Setup
- Reactive Props Destructure
- withDefaults Union Types
- Strict Template Checking
---
Extract Component Props
Use vue-component-type-helpers to extract types from .vue components:
npm install -D vue-component-type-helpersimport type { ComponentProps, ComponentEmit, ComponentSlots, ComponentExposed } from 'vue-component-type-helpers'
import MyButton from './MyButton.vue'
type Props = ComponentProps<typeof MyButton>
type Emits = ComponentEmit<typeof MyButton>
type Slots = ComponentSlots<typeof MyButton>
type Exposed = ComponentExposed<typeof MyButton>Wrapper Component Pattern
import type { ComponentProps } from 'vue-component-type-helpers'
import BaseButton from './BaseButton.vue'
type BaseProps = ComponentProps<typeof BaseButton>
interface Props extends BaseProps {
size: 'sm' | 'md' | 'lg'
}
defineProps<Props>()Do NOT Use
// ❌ Includes Vue internal properties (onUpdate:*, class, style, etc.)
type Props = InstanceType<typeof MyButton>['$props']Vue's built-in ExtractPropTypes is for runtime props objects (props: { foo: String }), not for .vue components.
Reference: vue-component-type-helpers
---
Generic Components
Create type-safe generic components using the generic attribute on <script>.
Basic Generic Component
<script setup lang="ts" generic="T">
defineProps<{
items: T[]
selected?: T
}>()
defineEmits<{
select: [item: T]
}>()
</script>
<template>
<ul>
<li v-for="item in items" @click="$emit('select', item)">
<slot :item="item" />
</li>
</ul>
</template>With Constraints
<script setup lang="ts" generic="T extends { id: string | number }">
defineProps<{
items: T[]
selectedId?: T['id']
}>()
</script>Multiple Type Parameters
<script setup lang="ts" generic="T, U extends keyof T">
defineProps<{
data: T
field: U
}>()
</script>Using Generic Components
<template>
<!-- TypeScript infers T from items prop -->
<GenericList :items="users" @select="handleUser">
<template #default="{ item }">
{{ item.name }} <!-- item is typed as User -->
</template>
</GenericList>
</template>Reference: Vue TypeScript with Composition API
---
useTemplateRef Typing
Vue 3.5 introduced useTemplateRef() for cleaner template ref management with automatic type inference.
Basic Usage
<script setup lang="ts">
import { useTemplateRef, onMounted } from 'vue'
// Type is inferred as ShallowRef<HTMLInputElement | null>
const inputRef = useTemplateRef('input')
onMounted(() => {
inputRef.value?.focus()
})
</script>
<template>
<input ref="input" />
</template>With Component Refs
<script setup lang="ts">
import { useTemplateRef } from 'vue'
import type { ComponentExposed } from 'vue-component-type-helpers'
import MyForm from './MyForm.vue'
// For generic components, use ComponentExposed
type FormExposed = ComponentExposed<typeof MyForm>
const formRef = useTemplateRef<InstanceType<typeof MyForm>>('form')
function submit() {
formRef.value?.validate()
}
</script>
<template>
<MyForm ref="form" />
</template>In Composables
// composables/useChart.ts
import { useTemplateRef, onMounted, onUnmounted } from 'vue'
export function useChart(refName: string) {
const canvasRef = useTemplateRef<HTMLCanvasElement>(refName)
let chart: Chart | null = null
onMounted(() => {
if (canvasRef.value) {
chart = new Chart(canvasRef.value, { /* config */ })
}
})
onUnmounted(() => {
chart?.destroy()
})
return { canvasRef, chart }
}Note: With @vue/language-tools 2.1+, static template refs' types are automatically inferred. Manual typing is only needed for edge cases.Reference: Vue Template Refs
---
JSDoc for Script Setup
<script setup> doesn't have an obvious place to attach JSDoc comments for the component itself. Use a dual-script pattern.
Problem
<script setup lang="ts">
/**
* This comment doesn't appear in IDE hover or docs
* @component
*/
import { ref } from 'vue'
const count = ref(0)
</script>JSDoc comments inside <script setup> don't attach to the component export because there's no explicit export statement.
Solution
Use both <script> and <script setup> blocks:
<script lang="ts">
/**
* A counter component that displays and increments a value.
*
* @example
* ```vue
* <Counter :initial="5" @update="handleUpdate" />
* ```
*
* @component
*/
export default {}
</script>
<script setup lang="ts">
import { ref } from 'vue'
const props = defineProps<{
/** Starting value for the counter */
initial?: number
}>()
const emit = defineEmits<{
/** Emitted when counter value changes */
update: [value: number]
}>()
const count = ref(props.initial ?? 0)
</script>What Gets Documented
| Location | Shows In |
|---|---|
export default {} JSDoc | Component import hover |
defineProps JSDoc | Prop hover in templates |
defineEmits JSDoc | Event handler hover |
Reference: Vue Language Tools Discussion #5932
---
Reactive Props Destructure
Vue 3.5 stabilized Reactive Props Destructure, enabled by default. Variables destructured from defineProps are automatically reactive.
Basic Pattern
<script setup lang="ts">
// Destructured props are reactive - no ref() needed
const { name, count = 0 } = defineProps<{
name: string
count?: number
}>()
// Access directly in script
console.log(name, count)
</script>
<template>
<!-- Access directly in template -->
<div>{{ name }}: {{ count }}</div>
</template>Watching Destructured Props
Wrap in a getter when watching or passing to composables:
const { count } = defineProps<{ count: number }>()
// ✅ Correct - wrap in getter
watch(() => count, (newVal) => {
console.log('count changed:', newVal)
})
// ❌ Incorrect - loses reactivity
watch(count, handler) // Won't work as expectedWith Default Values
<script setup lang="ts">
// Defaults work naturally with destructuring
const {
title = 'Default Title',
items = [],
config = { debug: false }
} = defineProps<{
title?: string
items?: string[]
config?: { debug: boolean }
}>()
</script>Enable for Vue < 3.5
// vite.config.js
export default {
plugins: [
vue({
script: {
propsDestructure: true
}
})
]
}Reference: Reactive Props Destructure RFC
---
withDefaults Union Types
Using withDefaults with union types like false | string may produce a Vue runtime warning "Missing required prop" even when a default is provided.
Symptoms
- Vue warns "Missing required prop" despite default being set
- Warning appears only with union types like
false | string - TypeScript types are correct
- Runtime value IS correct (the default is applied)
Problematic Pattern
// This produces a spurious warning (but works at runtime)
interface Props {
value: false | string // Union type
}
const props = withDefaults(defineProps<Props>(), {
value: 'default' // Runtime value IS correct, but Vue warns about missing prop
})Fix
Option 1: Use Reactive Props Destructure (Vue 3.5+) - Recommended
<script setup lang="ts">
interface Props {
value: false | string
}
// Preferred in Vue 3.5+
const { value = 'default' } = defineProps<Props>()
</script>Option 2: Use runtime declaration
<script setup lang="ts">
const props = defineProps({
value: {
type: [Boolean, String] as PropType<false | string>,
default: 'default'
}
})
</script>Option 3: Split into separate props
interface Props {
enabled: boolean
customValue?: string
}
const props = withDefaults(defineProps<Props>(), {
enabled: false,
customValue: 'default'
})Reference: vuejs/core#12897
---
Strict Template Checking
By default, vue-tsc does not report errors for undefined components in templates. Enable strictTemplates to catch these issues during type checking.
Which tsconfig?
Add vueCompilerOptions to the tsconfig that includes Vue source files. In projects with multiple tsconfigs (like those created with create-vue), this is typically tsconfig.app.json, not the root tsconfig.json or tsconfig.node.json.
Incorrect (missing strict checking):
{
"compilerOptions": {
"strict": true
}
// vueCompilerOptions not configured - undefined components won't error
}Correct (strict template checking enabled):
{
"compilerOptions": {
"strict": true
},
"vueCompilerOptions": {
"strictTemplates": true
}
}Available Options
| Option | Default | Effect |
|---|---|---|
strictTemplates | false | Enables all checkUnknown* options below |
checkUnknownComponents | false | Error on undefined/unregistered components |
checkUnknownProps | false | Error on props not declared in component definition |
checkUnknownEvents | false | Error on events not declared via defineEmits |
checkUnknownDirectives | false | Error on unregistered custom directives |
Granular Control
If strictTemplates is too strict, enable individual checks:
{
"vueCompilerOptions": {
"checkUnknownComponents": true,
"checkUnknownProps": false
}
}Reference: Vue Compiler Options
Volar Configuration
Table of Contents
- Volar 3.0 Breaking Changes
- vueCompilerOptions Overview
- Strict CSS Modules
- Fallthrough Attributes
- Data Attributes Allowlist
- Vue Directive Comments
- Code Actions Performance
---
Volar 3.0 Breaking Changes
Volar 3.0 (vue-language-server 3.x) introduced breaking changes to the language server protocol. Editors configured for Volar 2.x will break.
Symptoms
vue_ls doesn't work with ts_ls- TypeScript features stop working in Vue files
- No autocomplete, type hints, or error highlighting
- Editor shows "Language server initialization failed"
Fix by Editor
VSCode
Update the "Vue - Official" extension to latest version. It manages the language server automatically.
NeoVim (nvim-lspconfig)
Option 1: Use vtsls instead of ts_ls
-- Replace ts_ls/tsserver with vtsls
require('lspconfig').vtsls.setup({})
require('lspconfig').volar.setup({})Option 2: Downgrade vue-language-server
npm install -g @vue/language-server@2.1.10JetBrains IDEs
Update to latest Vue plugin. If issues persist, disable and re-enable the Vue plugin.
What Changed in 3.0
| Feature | Volar 2.x | Volar 3.0 |
|---|---|---|
| TypeScript integration | ts_ls/tsserver | vtsls recommended (Neovim) |
| Hybrid mode | Optional | Default |
Reference: vuejs/language-tools#5598
---
vueCompilerOptions Overview
| Option | Default | Effect |
|---|---|---|
strictTemplates | false | Enables all checkUnknown* options |
checkUnknownComponents | false | Error on undefined components |
checkUnknownProps | false | Error on undeclared props |
checkUnknownEvents | false | Error on undeclared events |
checkUnknownDirectives | false | Error on unregistered directives |
strictCssModules | false | Check CSS module class names |
fallthroughAttributes | false | Enable IDE autocomplete for $attrs |
dataAttributes | [] | Allow specific data-* patterns |
---
Strict CSS Modules
When using CSS modules with <style module>, Vue doesn't validate class names by default. Enable strictCssModules to catch typos.
Problem
<script setup lang="ts">
// No error for typo in class name
</script>
<template>
<div :class="$style.buttn">Click me</div>
</template>
<style module>
.button {
background: blue;
}
</style>The typo buttn instead of button silently fails at runtime.
Solution
// tsconfig.json or tsconfig.app.json
{
"vueCompilerOptions": {
"strictCssModules": true
}
}What Gets Checked
| Access | With strictCssModules |
|---|---|
$style.validClass | OK |
$style.typo | Error: Property 'typo' does not exist |
$style['dynamic'] | OK (dynamic access not checked) |
Limitations: Only checks static property access. Dynamic access ($style[variable]) is not validated.
Reference: Vue Language Tools Wiki - Vue Compiler Options
---
Fallthrough Attributes
When building component libraries with wrapper components, enable fallthroughAttributes to get IDE autocomplete for attributes that will be forwarded to child elements.
What It Does
<!-- MyButton.vue - wrapper around native button -->
<template>
<button v-bind="$attrs"><slot /></button>
</template>Solution
// tsconfig.json or tsconfig.app.json
{
"vueCompilerOptions": {
"fallthroughAttributes": true
}
}When fallthroughAttributes: true:
- Vue Language Server analyzes which element receives
$attrs - IDE autocomplete suggests valid attributes for the target element
Note: This primarily enables IDE autocomplete for valid fallthrough attributes. It does NOT reject invalid attributes as type errors.
Reference: Vue Language Tools Wiki - Vue Compiler Options
---
Data Attributes Allowlist
With strictTemplates enabled, data-* attributes on components cause type errors. Use the dataAttributes option to allow specific patterns.
Problem
<template>
<!-- Error: Property 'data-testid' does not exist on type... -->
<MyComponent data-testid="submit-button" />
</template>Solution
// tsconfig.json or tsconfig.app.json
{
"vueCompilerOptions": {
"strictTemplates": true,
"dataAttributes": ["data-*"]
}
}Specific Patterns
You can be more selective:
{
"vueCompilerOptions": {
"dataAttributes": [
"data-testid",
"data-cy",
"data-test-*"
]
}
}Common Testing Attributes
| Library | Attribute | Pattern |
|---|---|---|
| Testing Library | data-testid | "data-testid" |
| Cypress | data-cy | "data-cy" |
| Playwright | data-testid | "data-testid" |
| Generic | All data attributes | "data-*" |
Reference: Vue Language Tools Wiki - Vue Compiler Options
---
Vue Directive Comments
Vue Language Tools supports special directive comments to control type checking behavior in templates.
@vue-ignore
Suppress type errors for the next line:
<template>
<!-- @vue-ignore -->
<Component :prop="valueWithTypeError" />
</template>@vue-expect-error
Assert that the next line should have a type error (useful for testing):
<template>
<!-- @vue-expect-error -->
<Component :invalid-prop="value" />
</template>@vue-skip
Skip type checking for an entire block:
<template>
<!-- @vue-skip -->
<div>
<!-- Everything in here is not type-checked -->
<LegacyComponent :any="props" :go="here" />
</div>
</template>@vue-generic
Declare template-level generic types:
<template>
<!-- @vue-generic {T extends string} -->
<GenericList :items="items as T[]" />
</template>Use Cases
- Migrating legacy components with incomplete types
- Working with third-party components that have incorrect type definitions
- Temporarily suppressing errors during refactoring
- Testing that certain patterns produce expected type errors
Reference: Vue Language Tools Wiki - Directive Comments
---
Code Actions Performance
In large Vue projects, saving files can take 30-60+ seconds due to VSCode's code actions triggering expensive TypeScript state synchronization.
Symptoms
- Save operation takes 30+ seconds
- Editor becomes unresponsive during save
- CPU spikes when saving Vue files
Solution
Option 1: Disable code actions (fastest)
// .vscode/settings.json
{
"vue.codeActions.enabled": false
}Option 2: Limit code action time
// .vscode/settings.json
{
"vue.codeActions.savingTimeLimit": 1000
}Option 3: Disable specific code actions
// .vscode/settings.json
{
"vue.codeActions.enabled": true,
"editor.codeActionsOnSave": {
"source.organizeImports": "never"
}
}Additional Optimizations
// .vscode/settings.json
{
"vue.codeActions.enabled": false,
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {},
"[vue]": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "Vue.volar"
}
}VSCode 1.81.0+ includes fixes that reduce save time issues.
Reference: Vue Language Tools Discussion #2740