Generated by claude-opus-4-5-20251101-thinking-32k on 2025-12-02, much comprehensive than the other two or three AI models.
# Vue Comprehensive Reference for Backend Developers
# 1. Mental Model Shift
# 1.1 Vue's Reactivity vs Backend State Management
# Backend: Explicit state changes, request/response cycle
class OrderService:
def __init__(self, db: Database):
self.db = db
def update_order(self, order_id: str, status: str) -> Order:
order = self.db.get(order_id)
order.status = status # Mutation
self.db.save(order) # Explicit persistence
self.notify_subscribers(order) # Explicit notification
return order # Explicit return to caller// Frontend: Declarative reactivity - changes automatically propagate
import { ref, computed, watch } from 'vue'
const orderStatus = ref<string>('pending') // Reactive state
const isPending = computed(() => orderStatus.value === 'pending') // Auto-updates
// When this changes, ALL dependent UI automatically re-renders
orderStatus.value = 'shipped' // No manual notification needed| Backend Concept | Vue Equivalent | Key Difference |
|---|---|---|
| Database state | ref() / reactive() |
Lives in memory, triggers UI updates |
| Service methods | Composables / Component methods | No explicit "save" - mutations propagate |
| Event bus / Message queue | watch() / emit() |
Synchronous by default, same thread |
| Request/Response | Reactive data flow | No request needed - UI observes state |
| Repository pattern | Pinia stores | Centralized, but reactive |
| DTOs | TypeScript interfaces | Same concept, stricter at compile time |
// Vue wraps your data with Proxies (similar to Python descriptors or Java dynamic proxies)
import { ref, effect } from 'vue'
const count = ref(0)
// This is essentially what Vue's template compiler does:
effect(() => {
// This function re-runs whenever `count.value` changes
console.log(`Count is: ${count.value}`)
})
count.value++ // Console: "Count is: 1" (automatic!)
count.value++ // Console: "Count is: 2" (automatic!)Backend Analogy: Think of it like database triggers + materialized views. When base data changes, derived views automatically update.
# 1.2 Component Thinking and Service Architecture
┌─────────────────────────────────────────┐
│ Controllers/Handlers │ ← Entry points
├─────────────────────────────────────────┤
│ Services │ ← Business logic
├─────────────────────────────────────────┤
│ Repositories │ ← Data access
├─────────────────────────────────────────┤
│ Database │ ← Persistence
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ App.vue │ ← Root component
├─────────────────────────────────────────┤
│ ┌─────────────┐ ┌─────────────────┐ │
│ │ NavBar.vue │ │ MainContent.vue │ │ ← Layout components
│ └─────────────┘ └────────┬────────┘ │
│ │ │
│ ┌────────────────┼───────┐ │
│ ▼ ▼ ▼ │
│ ┌─────────┐ ┌───────────┐ ... │ ← Feature components
│ │OrderList│ │OrderDetail│ │
│ └────┬────┘ └───────────┘ │
│ ▼ │
│ ┌─────────┐ │ ← UI components
│ │OrderItem│ │
│ └─────────┘ │
├─────────────────────────────────────────┤
│ Composables (useOrders, useAuth) │ ← Shared logic (like services)
├─────────────────────────────────────────┤
│ Pinia Stores (orderStore, etc) │ ← Global state (like repositories)
└─────────────────────────────────────────┘
| Backend Layer | Vue Equivalent | Responsibility |
|---|---|---|
| Controller | Page/View Component | Route handling, orchestration |
| Service | Composable (use*.ts) |
Reusable business logic |
| Repository | Pinia Store | State management, data caching |
| DTO/Entity | TypeScript Interface | Type definitions |
| Middleware | Navigation Guards / Interceptors | Cross-cutting concerns |
<!-- OrderCard.vue -->
<script setup lang="ts">
// This is your "controller" logic - runs once on component mount
import { computed } from 'vue'
import type { Order } from '@/types'
// Props = Input parameters (like controller method params)
const props = defineProps<{
order: Order
}>()
// Emits = Output events (like return values or callbacks)
const emit = defineEmits<{
cancel: [orderId: string]
update: [order: Order]
}>()
// Computed = Derived data (like calculated fields)
const formattedTotal = computed(() =>
new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' })
.format(props.order.total)
)
// Methods = Actions
const handleCancel = () => {
emit('cancel', props.order.id)
}
</script>
<template>
<!-- This is your "view" layer - declarative HTML with bindings -->
<div class="order-card">
<h3>Order #{{ order.id }}</h3>
<p>Total: {{ formattedTotal }}</p>
<span :class="['status', order.status]">{{ order.status }}</span>
<button @click="handleCancel">Cancel Order</button>
</div>
</template>
<style scoped>
/* Scoped CSS - only affects this component (like CSS modules) */
.order-card {
padding: 1rem;
border: 1px solid #e0e0e0;
}
.status.pending { color: orange; }
.status.shipped { color: green; }
</style># 1.3 Template Syntax as Compiled Render Functions
Vue templates compile to optimized JavaScript render functions at build time.
<!-- What you write -->
<template>
<div>
<span>{{ message }}</span>
<button @click="increment">+1</button>
</div>
</template>// What Vue compiles it to (simplified)
import { openBlock, createElementBlock, createElementVNode, toDisplayString } from 'vue'
function render(_ctx) {
return (openBlock(), createElementBlock("div", null, [
createElementVNode("span", null, toDisplayString(_ctx.message), 1 /* TEXT */),
createElementVNode("button", { onClick: _ctx.increment }, "+1")
]))
}- It's not string interpolation - No XSS vulnerabilities from
{{ userInput }} - Compile-time optimization - Vue analyzes your template and generates optimized code
- Static hoisting - Static content is hoisted out of render function
- Patch flags - Vue knows exactly what can change (see the
1 /* TEXT */flag)
// Option 1: Template (recommended for most cases)
// In .vue file's <template> block
// Option 2: JSX (when you need more programmatic control)
const OrderList = defineComponent({
setup(props) {
return () => (
<div>
{props.orders.map(order => (
<OrderCard key={order.id} order={order} />
))}
</div>
)
}
})
// Option 3: Render function (rare, maximum control)
import { h } from 'vue'
const OrderList = defineComponent({
setup(props) {
return () => h('div', {},
props.orders.map(order =>
h(OrderCard, { key: order.id, order })
)
)
}
})Backend Analogy: Think of templates like SQL views or LINQ expressions - you write declarative syntax, the engine optimizes execution.
# 2. Syntax & Core Concepts
# 2.1 Template Syntax & Dynamic Content
<template>
<!-- Basic interpolation (auto-escaped, XSS-safe) -->
<p>{{ message }}</p>
<!-- JavaScript expressions (not statements!) -->
<p>{{ message.toUpperCase() }}</p>
<p>{{ items.length > 0 ? 'Has items' : 'Empty' }}</p>
<p>{{ formatDate(order.createdAt) }}</p>
<!-- Raw HTML (use with caution - XSS risk if user-generated) -->
<div v-html="trustedHtmlContent"></div>
</template><script setup lang="ts">
import { ref, computed } from 'vue'
const imageUrl = ref('/images/product.jpg')
const isActive = ref(true)
const buttonType = ref<'submit' | 'button'>('submit')
const inputAttrs = ref({
id: 'email',
placeholder: 'Enter email',
'data-testid': 'email-input'
})
</script>
<template>
<!-- Dynamic attribute binding with : (shorthand for v-bind:) -->
<img :src="imageUrl" :alt="product.name">
<!-- Boolean attributes (renders or omits based on truthiness) -->
<button :disabled="isLoading">Submit</button>
<!-- Dynamic attribute name -->
<button :[dynamicAttrName]="value">Click</button>
<!-- Class bindings -->
<div :class="{ active: isActive, 'text-danger': hasError }"></div>
<div :class="[baseClass, isActive ? 'active' : '']"></div>
<div :class="[{ active: isActive }, errorClass]"></div>
<!-- Style bindings -->
<div :style="{ color: textColor, fontSize: fontSize + 'px' }"></div>
<div :style="[baseStyles, overrideStyles]"></div>
<!-- Bind multiple attributes at once -->
<input v-bind="inputAttrs">
<!-- Equivalent to: <input :id="inputAttrs.id" :placeholder="inputAttrs.placeholder" ...> -->
</template><script setup lang="ts">
const count = ref(0)
// Inline handlers for simple operations
// Method handlers for complex logic
const handleClick = (event: MouseEvent) => {
console.log('Clicked at:', event.clientX, event.clientY)
}
const handleSubmit = async (event: Event) => {
event.preventDefault()
// Process form...
}
const handleKeydown = (event: KeyboardEvent, customArg: string) => {
console.log('Key:', event.key, 'Custom:', customArg)
}
</script>
<template>
<!-- Basic event with @ (shorthand for v-on:) -->
<button @click="count++">{{ count }}</button>
<!-- Method handler (receives native event) -->
<button @click="handleClick">Click Me</button>
<!-- Passing arguments AND accessing event -->
<input @keydown="handleKeydown($event, 'custom-value')">
<!-- Event modifiers (replaces common event handling patterns) -->
<form @submit.prevent="handleSubmit"> <!-- event.preventDefault() -->
<button @click.stop="doSomething"> <!-- event.stopPropagation() -->
<a @click.prevent>Disabled Link</a> <!-- Prevent default only -->
</form>
<!-- Key modifiers -->
<input @keyup.enter="submitForm"> <!-- Only on Enter key -->
<input @keyup.ctrl.enter="submitForm"> <!-- Ctrl + Enter -->
<input @keydown.esc="closeModal">
<!-- Mouse modifiers -->
<button @click.right="openContextMenu">Right Click</button>
<button @click.middle="openInNewTab">Middle Click</button>
<!-- Once modifier (like { once: true } in addEventListener) -->
<button @click.once="trackFirstInteraction">Track Once</button>
<!-- Passive modifier (for scroll performance) -->
<div @scroll.passive="onScroll">Scrollable content</div>
</template># 2.2 Composition API with TypeScript
<!-- UserProfile.vue -->
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useUserStore } from '@/stores/user'
import type { User, UserUpdatePayload } from '@/types'
// ============ TYPE DEFINITIONS ============
interface Props {
userId: string
showActions?: boolean // Optional with default
}
// ============ PROPS ============
const props = withDefaults(defineProps<Props>(), {
showActions: true
})
// ============ EMITS ============
const emit = defineEmits<{
(e: 'update', payload: UserUpdatePayload): void
(e: 'delete', userId: string): void
// Vue 3.3+ alternative syntax:
// update: [payload: UserUpdatePayload]
// delete: [userId: string]
}>()
// ============ COMPOSABLES / DEPENDENCIES ============
const route = useRoute()
const userStore = useUserStore()
// ============ STATE ============
const isEditing = ref(false)
const formData = ref<Partial<User>>({})
const validationErrors = ref<Record<string, string>>({})
// ============ COMPUTED ============
const user = computed(() => userStore.getUserById(props.userId))
const fullName = computed(() =>
user.value ? `${user.value.firstName} ${user.value.lastName}` : ''
)
const isValid = computed(() => Object.keys(validationErrors.value).length === 0)
// ============ METHODS ============
const startEditing = () => {
formData.value = { ...user.value }
isEditing.value = true
}
const saveChanges = async () => {
if (!isValid.value) return
emit('update', {
userId: props.userId,
changes: formData.value
})
isEditing.value = false
}
const handleDelete = () => {
if (confirm('Are you sure?')) {
emit('delete', props.userId)
}
}
// ============ LIFECYCLE ============
onMounted(async () => {
if (!user.value) {
await userStore.fetchUser(props.userId)
}
})
</script>
<template>
<div class="user-profile">
<template v-if="user">
<h2>{{ fullName }}</h2>
<p>{{ user.email }}</p>
<div v-if="showActions" class="actions">
<button @click="startEditing">Edit</button>
<button @click="handleDelete" class="danger">Delete</button>
</div>
</template>
<p v-else>Loading user...</p>
</div>
</template><!-- ChildComponent.vue -->
<script setup lang="ts">
import { ref } from 'vue'
const internalCounter = ref(0)
const reset = () => {
internalCounter.value = 0
}
const increment = () => {
internalCounter.value++
}
// Explicitly expose to parent (like public methods in a class)
defineExpose({
reset,
increment,
// Can also expose refs
counter: internalCounter
})
</script><!-- ParentComponent.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import ChildComponent from './ChildComponent.vue'
// Template ref to access child
const childRef = ref<InstanceType<typeof ChildComponent> | null>(null)
const resetChild = () => {
childRef.value?.reset()
console.log(childRef.value?.counter) // Access exposed state
}
</script>
<template>
<ChildComponent ref="childRef" />
<button @click="resetChild">Reset Child</button>
</template># 2.3 ref() vs reactive() vs computed()
| Feature | ref() |
reactive() |
computed() |
|---|---|---|---|
| Use case | Any value | Objects/arrays only | Derived values |
| Access | .value in JS |
Direct access | .value (readonly) |
| Reassign | ✅ Yes | ❌ No (lose reactivity) | ❌ No (derived) |
| Template | Auto-unwrapped | Direct access | Auto-unwrapped |
| Destructure | ❌ Loses reactivity | ❌ Loses reactivity | N/A |
import { ref, Ref } from 'vue'
// Primitives
const count = ref(0) // Ref<number>
const name = ref<string | null>(null) // Explicit typing for unions
const isLoading = ref(false) // Ref<boolean>
// Objects (wraps in reactive internally)
const user = ref<User | null>(null) // Ref<User | null>
// Access/modify with .value
count.value++
user.value = { id: '1', name: 'John' }
// Can be reassigned entirely
const items = ref<Item[]>([])
items.value = await fetchItems() // ✅ Works!
// Type helper for refs
function useCounter(): { count: Ref<number>; increment: () => void } {
const count = ref(0)
const increment = () => count.value++
return { count, increment }
}import { reactive, toRefs } from 'vue'
interface FormState {
email: string
password: string
rememberMe: boolean
errors: Record<string, string>
}
// Creates a reactive proxy (like Vue 2's data())
const form = reactive<FormState>({
email: '',
password: '',
rememberMe: false,
errors: {}
})
// Direct access (no .value needed)
form.email = '[email protected]'
form.errors.email = 'Invalid format'
// ⚠️ WARNING: Cannot reassign the whole object!
// form = { ...form, email: 'new@example.com' } // ❌ Loses reactivity!
// Use Object.assign or individual properties
Object.assign(form, initialState) // ✅ Maintains reactivity
// Converting to refs for composable returns
function useForm() {
const form = reactive<FormState>({
email: '',
password: '',
rememberMe: false,
errors: {}
})
// toRefs converts each property to a ref
return {
...toRefs(form), // { email: Ref<string>, password: Ref<string>, ... }
submit: () => { /* ... */ }
}
}
// Usage maintains reactivity when destructured
const { email, password, submit } = useForm()
email.value = '[email protected]' // ✅ Still reactive!import { ref, computed, ComputedRef } from 'vue'
const firstName = ref('John')
const lastName = ref('Doe')
const items = ref<Item[]>([])
const filter = ref<'all' | 'active' | 'completed'>('all')
// Read-only computed
const fullName: ComputedRef<string> = computed(() =>
`${firstName.value} ${lastName.value}`
)
// Computed with complex logic (automatically cached)
const filteredItems = computed(() => {
console.log('Filtering...') // Only runs when dependencies change
switch (filter.value) {
case 'active':
return items.value.filter(item => !item.completed)
case 'completed':
return items.value.filter(item => item.completed)
default:
return items.value
}
})
// Computed with getter/setter (writable computed)
const fullNameWritable = computed({
get: () => `${firstName.value} ${lastName.value}`,
set: (newValue: string) => {
const [first, ...rest] = newValue.split(' ')
firstName.value = first
lastName.value = rest.join(' ')
}
})
fullNameWritable.value = 'Jane Smith' // Sets firstName='Jane', lastName='Smith'
// Typed computed for generics
function useFiltered<T>(
items: Ref<T[]>,
predicate: (item: T) => boolean
): ComputedRef<T[]> {
return computed(() => items.value.filter(predicate))
}| Vue Concept | Backend Equivalent |
|---|---|
ref() |
Mutable variable with change tracking (Observable pattern) |
reactive() |
Entity/Model object with property change detection |
computed() |
Materialized view / Cached derived property |
# 2.4 Component Lifecycle Hooks
Component Instance
│
▼
┌─────────────┐
│ setup() │ ← Composition API entry (no hook needed)
└──────┬──────┘
│
▼
┌─────────────────┐
│ onBeforeMount() │ ← Before DOM insertion
└────────┬────────┘
│
▼
┌─────────────┐
│ onMounted() │ ← DOM ready, refs accessible
└──────┬──────┘
│
│ (reactive data changes)
▼
┌──────────────────┐
│ onBeforeUpdate() │ ← Before re-render
└────────┬─────────┘
│
▼
┌─────────────┐
│ onUpdated() │ ← After re-render
└──────┬──────┘
│
│ (component unmounting)
▼
┌───────────────────┐
│ onBeforeUnmount() │ ← Before teardown
└─────────┬─────────┘
│
▼
┌───────────────┐
│ onUnmounted() │ ← Cleanup complete
└───────────────┘
<script setup lang="ts">
import {
onBeforeMount,
onMounted,
onBeforeUpdate,
onUpdated,
onBeforeUnmount,
onUnmounted,
onErrorCaptured,
onActivated,
onDeactivated,
ref
} from 'vue'
const data = ref<DataType | null>(null)
let websocket: WebSocket | null = null
let intervalId: number | null = null
// ═══════════════════════════════════════════════════════════
// onMounted - Most commonly used
// Backend equivalent: @PostConstruct, __init__ after DI complete
// ═══════════════════════════════════════════════════════════
onMounted(async () => {
// DOM is ready - can access template refs
// Fetch initial data
data.value = await fetchData()
// Set up subscriptions
websocket = new WebSocket('wss://api.example.com')
websocket.onmessage = handleMessage
// Start intervals
intervalId = window.setInterval(pollForUpdates, 30000)
})
// ═══════════════════════════════════════════════════════════
// onUnmounted - Critical for cleanup
// Backend equivalent: @PreDestroy, __del__, IDisposable.Dispose()
// ═══════════════════════════════════════════════════════════
onUnmounted(() => {
// Prevent memory leaks!
websocket?.close()
if (intervalId) clearInterval(intervalId)
})
// ═══════════════════════════════════════════════════════════
// onBeforeMount
// Backend equivalent: Late initialization before service starts
// ═══════════════════════════════════════════════════════════
onBeforeMount(() => {
console.log('Component will mount - DOM not yet available')
})
// ═══════════════════════════════════════════════════════════
// onBeforeUpdate / onUpdated
// Backend equivalent: Database triggers, @PreUpdate/@PostUpdate
// ═══════════════════════════════════════════════════════════
onBeforeUpdate(() => {
console.log('Data changed, about to re-render')
})
onUpdated(() => {
console.log('DOM updated to reflect new data')
// Careful: Don't mutate state here (infinite loop!)
})
// ═══════════════════════════════════════════════════════════
// onErrorCaptured - Error boundary
// Backend equivalent: Exception handler middleware, @ExceptionHandler
// ═══════════════════════════════════════════════════════════
onErrorCaptured((error, instance, info) => {
console.error('Error in child component:', error)
console.log('Component:', instance)
console.log('Info:', info)
// Report to monitoring service
errorReporter.capture(error, { componentInfo: info })
// Return false to propagate, true to stop propagation
return false
})
// ═══════════════════════════════════════════════════════════
// onActivated / onDeactivated - For <KeepAlive> cached components
// Backend equivalent: Connection pool acquire/release
// ═══════════════════════════════════════════════════════════
onActivated(() => {
// Component reactivated from cache
console.log('Component brought back from cache')
})
onDeactivated(() => {
// Component cached instead of destroyed
console.log('Component cached')
})
</script>import { onServerPrefetch } from 'vue'
// Only runs during Server-Side Rendering
onServerPrefetch(async () => {
// Fetch data needed for SSR
// Equivalent to getServerSideProps in Next.js
await store.fetchInitialData()
})# 2.5 Template Directives
<script setup lang="ts">
import { ref, computed } from 'vue'
type Status = 'pending' | 'processing' | 'completed' | 'failed'
const user = ref<User | null>(null)
const isLoading = ref(true)
const status = ref<Status>('pending')
const items = ref<Item[]>([])
</script>
<template>
<!-- Basic conditional -->
<div v-if="isLoading">Loading...</div>
<div v-else-if="user">Welcome, {{ user.name }}</div>
<div v-else>Please log in</div>
<!-- With template for multiple elements (no extra DOM wrapper) -->
<template v-if="user">
<h1>{{ user.name }}</h1>
<p>{{ user.email }}</p>
<UserActions :user="user" />
</template>
<!-- Complex conditions -->
<div v-if="status === 'completed' && items.length > 0">
Processing complete!
</div>
<!-- v-show: CSS-based toggle (keep in DOM, toggle display) -->
<!-- Use for frequent toggles to avoid mount/unmount cost -->
<div v-show="isDropdownOpen">
Dropdown content (always in DOM, just hidden)
</div>
</template>v-if vs v-show Decision Guide:
v-if: Higher toggle cost, lower initial cost. Use when condition rarely changes.v-show: Lower toggle cost, higher initial cost. Use for frequent toggles.
<script setup lang="ts">
interface Item {
id: string
name: string
category: string
}
const items = ref<Item[]>([
{ id: '1', name: 'Item 1', category: 'A' },
{ id: '2', name: 'Item 2', category: 'B' },
])
const objectData = ref({
name: 'Product',
price: 29.99,
inStock: true
})
</script>
<template>
<!-- Always use :key with a unique identifier (not index!) -->
<ul>
<li v-for="item in items" :key="item.id">
{{ item.name }}
</li>
</ul>
<!-- With index (when needed) -->
<div v-for="(item, index) in items" :key="item.id">
{{ index + 1 }}. {{ item.name }}
</div>
<!-- Iterating over object properties -->
<div v-for="(value, key, index) in objectData" :key="key">
{{ index }}. {{ key }}: {{ value }}
</div>
<!-- Range (1 to n) -->
<span v-for="n in 5" :key="n">{{ n }}</span>
<!-- Output: 1 2 3 4 5 -->
<!-- With <template> for multiple elements -->
<template v-for="item in items" :key="item.id">
<dt>{{ item.name }}</dt>
<dd>{{ item.category }}</dd>
</template>
<!-- ⚠️ v-if with v-for: Use computed or template wrapper -->
<!-- BAD: v-for and v-if on same element -->
<!-- GOOD: Filter in computed -->
<li v-for="item in activeItems" :key="item.id">
{{ item.name }}
</li>
<!-- GOOD: Wrap v-if in template -->
<template v-for="item in items" :key="item.id">
<li v-if="item.isActive">{{ item.name }}</li>
</template>
</template>
<script setup lang="ts">
const activeItems = computed(() =>
items.value.filter(item => item.isActive)
)
</script><script setup lang="ts">
const text = ref('')
const selected = ref<string[]>([])
const toggle = ref(false)
const rating = ref(0)
</script>
<template>
<!-- Basic text input -->
<input v-model="text" type="text">
<!-- Modifiers -->
<input v-model.lazy="text"> <!-- Sync on 'change' instead of 'input' -->
<input v-model.number="rating"> <!-- Auto-convert to number -->
<input v-model.trim="text"> <!-- Trim whitespace -->
<input v-model.lazy.trim="text"> <!-- Can combine modifiers -->
<!-- Textarea -->
<textarea v-model="text"></textarea>
<!-- Checkbox (boolean) -->
<input v-model="toggle" type="checkbox">
<!-- Checkbox (array of values) -->
<input v-model="selected" type="checkbox" value="option1">
<input v-model="selected" type="checkbox" value="option2">
<!-- Radio -->
<input v-model="selected" type="radio" value="option1">
<input v-model="selected" type="radio" value="option2">
<!-- Select -->
<select v-model="selected">
<option value="">Choose...</option>
<option value="a">Option A</option>
<option value="b">Option B</option>
</select>
<!-- Select multiple -->
<select v-model="selected" multiple>
<option value="a">A</option>
<option value="b">B</option>
</select>
</template><!-- CustomInput.vue -->
<script setup lang="ts">
const model = defineModel<string>() // Vue 3.4+
// Or with options
const model = defineModel<string>({ required: true })
</script>
<template>
<input :value="model" @input="model = ($event.target as HTMLInputElement).value">
</template><!-- Parent.vue -->
<template>
<!-- These are equivalent -->
<CustomInput v-model="searchQuery" />
<CustomInput :modelValue="searchQuery" @update:modelValue="val => searchQuery = val" />
</template><!-- UserForm.vue -->
<script setup lang="ts">
const firstName = defineModel<string>('firstName')
const lastName = defineModel<string>('lastName')
const email = defineModel<string>('email')
</script>
<template>
<input v-model="firstName" placeholder="First name">
<input v-model="lastName" placeholder="Last name">
<input v-model="email" type="email" placeholder="Email">
</template><!-- Parent.vue -->
<template>
<UserForm
v-model:firstName="user.firstName"
v-model:lastName="user.lastName"
v-model:email="user.email"
/>
</template># 2.6 Slots & Scoped Slots
<!-- Card.vue - The slot provider -->
<template>
<div class="card">
<div class="card-header">
<slot name="header">Default Header</slot>
</div>
<div class="card-body">
<slot>Default content (unnamed/default slot)</slot>
</div>
<div class="card-footer">
<slot name="footer"></slot>
</div>
</div>
</template><!-- Parent.vue - Using the slots -->
<template>
<Card>
<!-- Named slot with #shorthand (v-slot:header) -->
<template #header>
<h2>My Custom Header</h2>
</template>
<!-- Default slot content (no template needed for simple cases) -->
<p>This goes in the default slot</p>
<p>Multiple elements are fine</p>
<!-- Named slot with v-slot: syntax -->
<template v-slot:footer>
<button>Save</button>
<button>Cancel</button>
</template>
</Card>
</template><!-- DataTable.vue - Pass data to parent via slot props -->
<script setup lang="ts">
interface Column<T> {
key: keyof T
label: string
}
interface Props<T> {
items: T[]
columns: Column<T>[]
}
const props = defineProps<Props<any>>()
</script>
<template>
<table>
<thead>
<tr>
<th v-for="col in columns" :key="String(col.key)">
<!-- Header slot with column data -->
<slot name="header" :column="col">
{{ col.label }}
</slot>
</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, index) in items" :key="index">
<td v-for="col in columns" :key="String(col.key)">
<!-- Cell slot with full context -->
<slot
name="cell"
:item="item"
:column="col"
:value="item[col.key]"
:index="index"
>
{{ item[col.key] }}
</slot>
</td>
</tr>
</tbody>
</table>
</template><!-- Parent.vue - Receiving scoped slot data -->
<script setup lang="ts">
interface User {
id: string
name: string
email: string
status: 'active' | 'inactive'
}
const users = ref<User[]>([/* ... */])
const columns = [
{ key: 'name', label: 'Name' },
{ key: 'email', label: 'Email' },
{ key: 'status', label: 'Status' },
]
</script>
<template>
<DataTable :items="users" :columns="columns">
<!-- Destructure slot props -->
<template #cell="{ item, column, value }">
<!-- Custom rendering based on column -->
<template v-if="column.key === 'status'">
<StatusBadge :status="value" />
</template>
<template v-else-if="column.key === 'name'">
<RouterLink :to="`/users/${item.id}`">
{{ value }}
</RouterLink>
</template>
<template v-else>
{{ value }}
</template>
</template>
</DataTable>
</template><!-- RenderlessComponent.vue - Logic only, parent controls all rendering -->
<script setup lang="ts">
import { ref, computed } from 'vue'
const isOpen = ref(false)
const toggle = () => isOpen.value = !isOpen.value
const open = () => isOpen.value = true
const close = () => isOpen.value = false
// Expose everything via default slot
defineSlots<{
default(props: {
isOpen: boolean
toggle: () => void
open: () => void
close: () => void
}): any
}>()
</script>
<template>
<slot
:isOpen="isOpen"
:toggle="toggle"
:open="open"
:close="close"
/>
</template><!-- Usage -->
<template>
<Toggle v-slot="{ isOpen, toggle }">
<button @click="toggle">
{{ isOpen ? 'Close' : 'Open' }}
</button>
<div v-if="isOpen" class="dropdown">
Dropdown content
</div>
</Toggle>
</template># 2.7 Composables
Composables are Vue's equivalent of backend services/utilities - reusable functions that encapsulate reactive state and logic.
// composables/useCounter.ts
import { ref, computed, type Ref, type ComputedRef } from 'vue'
interface UseCounterOptions {
initialValue?: number
min?: number
max?: number
step?: number
}
interface UseCounterReturn {
count: Ref<number>
doubled: ComputedRef<number>
increment: () => void
decrement: () => void
reset: () => void
set: (value: number) => void
}
export function useCounter(options: UseCounterOptions = {}): UseCounterReturn {
const {
initialValue = 0,
min = -Infinity,
max = Infinity,
step = 1
} = options
const count = ref(initialValue)
const doubled = computed(() => count.value * 2)
const increment = () => {
if (count.value + step <= max) {
count.value += step
}
}
const decrement = () => {
if (count.value - step >= min) {
count.value -= step
}
}
const reset = () => {
count.value = initialValue
}
const set = (value: number) => {
count.value = Math.max(min, Math.min(max, value))
}
return {
count,
doubled,
increment,
decrement,
reset,
set
}
}// composables/useFetch.ts
import { ref, shallowRef, computed, watch, type Ref } from 'vue'
interface UseFetchOptions<T> {
immediate?: boolean
initialData?: T
onError?: (error: Error) => void
}
interface UseFetchReturn<T> {
data: Ref<T | null>
error: Ref<Error | null>
isLoading: Ref<boolean>
isSuccess: ComputedRef<boolean>
isError: ComputedRef<boolean>
execute: () => Promise<void>
refresh: () => Promise<void>
}
export function useFetch<T>(
url: string | Ref<string>,
options: UseFetchOptions<T> = {}
): UseFetchReturn<T> {
const { immediate = true, initialData = null, onError } = options
const data = shallowRef<T | null>(initialData)
const error = ref<Error | null>(null)
const isLoading = ref(false)
const isSuccess = computed(() => !isLoading.value && !error.value && data.value !== null)
const isError = computed(() => error.value !== null)
const execute = async () => {
const targetUrl = typeof url === 'string' ? url : url.value
isLoading.value = true
error.value = null
try {
const response = await fetch(targetUrl)
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
data.value = await response.json()
} catch (e) {
error.value = e instanceof Error ? e : new Error(String(e))
onError?.(error.value)
} finally {
isLoading.value = false
}
}
// Re-fetch when URL changes
if (typeof url !== 'string') {
watch(url, () => execute())
}
// Initial fetch
if (immediate) {
execute()
}
return {
data,
error,
isLoading,
isSuccess,
isError,
execute,
refresh: execute
}
}// composables/useWebSocket.ts
import { ref, onUnmounted, type Ref } from 'vue'
interface UseWebSocketOptions {
immediate?: boolean
reconnect?: boolean
reconnectInterval?: number
}
interface UseWebSocketReturn<T> {
data: Ref<T | null>
status: Ref<'CONNECTING' | 'OPEN' | 'CLOSING' | 'CLOSED'>
send: (message: string | object) => void
connect: () => void
disconnect: () => void
}
export function useWebSocket<T = any>(
url: string,
options: UseWebSocketOptions = {}
): UseWebSocketReturn<T> {
const { immediate = true, reconnect = true, reconnectInterval = 3000 } = options
const data = ref<T | null>(null) as Ref<T | null>
const status = ref<'CONNECTING' | 'OPEN' | 'CLOSING' | 'CLOSED'>('CLOSED')
let ws: WebSocket | null = null
let reconnectTimeout: ReturnType<typeof setTimeout> | null = null
const connect = () => {
if (ws?.readyState === WebSocket.OPEN) return
status.value = 'CONNECTING'
ws = new WebSocket(url)
ws.onopen = () => {
status.value = 'OPEN'
}
ws.onmessage = (event) => {
try {
data.value = JSON.parse(event.data)
} catch {
data.value = event.data as T
}
}
ws.onclose = () => {
status.value = 'CLOSED'
ws = null
if (reconnect) {
reconnectTimeout = setTimeout(connect, reconnectInterval)
}
}
ws.onerror = () => {
ws?.close()
}
}
const disconnect = () => {
if (reconnectTimeout) {
clearTimeout(reconnectTimeout)
reconnectTimeout = null
}
ws?.close()
ws = null
}
const send = (message: string | object) => {
if (ws?.readyState === WebSocket.OPEN) {
ws.send(typeof message === 'string' ? message : JSON.stringify(message))
}
}
// Auto-cleanup on component unmount
onUnmounted(() => {
disconnect()
})
if (immediate) {
connect()
}
return {
data,
status,
send,
connect,
disconnect
}
}<script setup lang="ts">
import { watch } from 'vue'
import { useFetch } from '@/composables/useFetch'
import { useWebSocket } from '@/composables/useWebSocket'
import { useCounter } from '@/composables/useCounter'
interface User {
id: string
name: string
}
// Multiple composables work together seamlessly
const {
data: users,
isLoading,
error,
refresh
} = useFetch<User[]>('/api/users')
const {
data: realtimeUpdate,
status: wsStatus
} = useWebSocket<{ type: string; payload: User }>('wss://api.example.com/ws')
const { count: page, increment: nextPage, decrement: prevPage } = useCounter({
initialValue: 1,
min: 1
})
// Composables can react to each other
watch(realtimeUpdate, (update) => {
if (update?.type === 'USER_UPDATED') {
refresh() // Re-fetch when websocket notifies of changes
}
})
</script>// ✅ DO: Start with "use" prefix
export function useAuth() { }
// ✅ DO: Accept refs OR values (use unref/toValue)
import { toValue, type MaybeRefOrGetter } from 'vue'
export function useFetch<T>(url: MaybeRefOrGetter<string>) {
const actualUrl = toValue(url) // Works with ref, getter, or raw value
}
// ✅ DO: Return object for destructuring
export function useCounter() {
return { count, increment, decrement } // Easy to pick what you need
}
// ✅ DO: Handle cleanup in the composable
export function useEventListener(target: EventTarget, event: string, handler: EventListener) {
onMounted(() => target.addEventListener(event, handler))
onUnmounted(() => target.removeEventListener(event, handler)) // Cleanup!
}
// ✅ DO: Use shallowRef for large objects
const items = shallowRef<LargeObject[]>([]) // Only tracks reference change
// ✅ DO: Make side effects optional
export function useFetch(url: string, { immediate = true } = {}) {
if (immediate) execute() // Caller can control when to fetch
}# 3. Modern Patterns & Best Practices
# 3.1 Component Composition Patterns
<!-- ❌ BAD: Prop drilling through multiple levels -->
<!-- App.vue → Layout.vue → Sidebar.vue → UserMenu.vue → Avatar.vue -->
<!-- App.vue -->
<template>
<Layout :user="user" :theme="theme" :notifications="notifications" />
</template>
<!-- Layout.vue - Just passing through! -->
<template>
<Sidebar :user="user" :theme="theme" :notifications="notifications" />
</template>
<!-- This continues for every level... --><!-- App.vue - Provide at the top -->
<script setup lang="ts">
import { provide, ref } from 'vue'
import type { User, Theme } from '@/types'
const user = ref<User | null>(null)
const theme = ref<Theme>('light')
// Provide with injection keys for type safety
provide('user', user)
provide('theme', theme)
</script><!-- Avatar.vue - Inject where needed (any depth) -->
<script setup lang="ts">
import { inject } from 'vue'
import type { Ref } from 'vue'
import type { User } from '@/types'
// Type-safe injection
const user = inject<Ref<User | null>>('user')
// With default value
const theme = inject('theme', ref('light'))
</script>// composables/useCurrentUser.ts
import { ref, computed, readonly } from 'vue'
import type { User } from '@/types'
// Module-level state (singleton pattern)
const user = ref<User | null>(null)
const isLoading = ref(false)
export function useCurrentUser() {
const isAuthenticated = computed(() => user.value !== null)
const login = async (credentials: { email: string; password: string }) => {
isLoading.value = true
try {
const response = await authApi.login(credentials)
user.value = response.user
} finally {
isLoading.value = false
}
}
const logout = () => {
user.value = null
}
return {
user: readonly(user), // Prevent external mutations
isLoading: readonly(isLoading),
isAuthenticated,
login,
logout
}
}<!-- Any component at any level -->
<script setup lang="ts">
import { useCurrentUser } from '@/composables/useCurrentUser'
const { user, isAuthenticated, logout } = useCurrentUser()
</script>
<template>
<div v-if="isAuthenticated">
Welcome, {{ user?.name }}
<button @click="logout">Logout</button>
</div>
</template>// stores/user.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { User } from '@/types'
export const useUserStore = defineStore('user', () => {
// State
const user = ref<User | null>(null)
const isLoading = ref(false)
// Getters
const isAuthenticated = computed(() => user.value !== null)
const fullName = computed(() =>
user.value ? `${user.value.firstName} ${user.value.lastName}` : ''
)
// Actions
async function login(credentials: { email: string; password: string }) {
isLoading.value = true
try {
const response = await authApi.login(credentials)
user.value = response.user
return { success: true }
} catch (error) {
return { success: false, error }
} finally {
isLoading.value = false
}
}
function logout() {
user.value = null
}
return {
user,
isLoading,
isAuthenticated,
fullName,
login,
logout
}
})<!-- components/Tabs/TabsRoot.vue -->
<script setup lang="ts">
import { provide, ref, type InjectionKey, type Ref } from 'vue'
export interface TabsContext {
activeTab: Ref<string>
setActiveTab: (id: string) => void
}
export const TabsKey: InjectionKey<TabsContext> = Symbol('Tabs')
const props = withDefaults(defineProps<{
defaultValue?: string
modelValue?: string
}>(), {
defaultValue: ''
})
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const internalValue = ref(props.defaultValue)
const activeTab = computed({
get: () => props.modelValue ?? internalValue.value,
set: (val) => {
internalValue.value = val
emit('update:modelValue', val)
}
})
provide(TabsKey, {
activeTab,
setActiveTab: (id: string) => { activeTab.value = id }
})
</script>
<template>
<div class="tabs">
<slot />
</div>
</template><!-- components/Tabs/TabsTrigger.vue -->
<script setup lang="ts">
import { inject, computed } from 'vue'
import { TabsKey, type TabsContext } from './TabsRoot.vue'
const props = defineProps<{
value: string
}>()
const context = inject(TabsKey) as TabsContext
const isActive = computed(() => context.activeTab.value === props.value)
</script>
<template>
<button
:class="['tab-trigger', { active: isActive }]"
@click="context.setActiveTab(value)"
>
<slot />
</button>
</template><!-- components/Tabs/TabsContent.vue -->
<script setup lang="ts">
import { inject, computed } from 'vue'
import { TabsKey, type TabsContext } from './TabsRoot.vue'
const props = defineProps<{
value: string
}>()
const context = inject(TabsKey) as TabsContext
const isActive = computed(() => context.activeTab.value === props.value)
</script>
<template>
<div v-if="isActive" class="tab-content">
<slot />
</div>
</template><!-- Usage - Clean, composable API -->
<template>
<Tabs v-model="activeTab">
<TabsList>
<TabsTrigger value="account">Account</TabsTrigger>
<TabsTrigger value="security">Security</TabsTrigger>
<TabsTrigger value="billing">Billing</TabsTrigger>
</TabsList>
<TabsContent value="account">
<AccountSettings />
</TabsContent>
<TabsContent value="security">
<SecuritySettings />
</TabsContent>
<TabsContent value="billing">
<BillingSettings />
</TabsContent>
</Tabs>
</template># 3.2 State Management: Local vs Pinia vs TanStack Query
| State Type | Solution | Example |
|---|---|---|
| UI state (single component) | ref() / reactive() |
Modal open/close, form inputs |
| UI state (shared) | Composable or Pinia | Theme, sidebar collapsed |
| Server cache | TanStack Query | API data, user lists |
| Global app state | Pinia | Current user, shopping cart |
| URL state | Vue Router | Filters, pagination |
| Form state | VeeValidate / FormKit | Complex forms |
<script setup lang="ts">
import { ref, reactive } from 'vue'
// Simple values
const isOpen = ref(false)
const searchQuery = ref('')
// Related state grouped together
const pagination = reactive({
page: 1,
pageSize: 20,
total: 0
})
// Form state
const form = reactive({
email: '',
password: '',
rememberMe: false
})
</script>// stores/cart.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
interface CartItem {
productId: string
name: string
price: number
quantity: number
}
export const useCartStore = defineStore('cart', () => {
// State
const items = ref<CartItem[]>([])
const isOpen = ref(false)
// Getters
const totalItems = computed(() =>
items.value.reduce((sum, item) => sum + item.quantity, 0)
)
const totalPrice = computed(() =>
items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
)
const isEmpty = computed(() => items.value.length === 0)
// Actions
function addItem(product: Omit<CartItem, 'quantity'>) {
const existing = items.value.find(i => i.productId === product.productId)
if (existing) {
existing.quantity++
} else {
items.value.push({ ...product, quantity: 1 })
}
}
function removeItem(productId: string) {
const index = items.value.findIndex(i => i.productId === productId)
if (index > -1) {
items.value.splice(index, 1)
}
}
function updateQuantity(productId: string, quantity: number) {
const item = items.value.find(i => i.productId === productId)
if (item) {
item.quantity = Math.max(0, quantity)
if (item.quantity === 0) {
removeItem(productId)
}
}
}
function clear() {
items.value = []
}
// Persistence (optional)
function $hydrate() {
const saved = localStorage.getItem('cart')
if (saved) {
items.value = JSON.parse(saved)
}
}
function $persist() {
localStorage.setItem('cart', JSON.stringify(items.value))
}
return {
items,
isOpen,
totalItems,
totalPrice,
isEmpty,
addItem,
removeItem,
updateQuantity,
clear,
$hydrate,
$persist
}
}, {
// Pinia plugin for automatic persistence
persist: true // Requires pinia-plugin-persistedstate
})// composables/useProducts.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/vue-query'
import type { Product, CreateProductDTO } from '@/types'
// Query keys factory (prevents typos, enables type safety)
export const productKeys = {
all: ['products'] as const,
lists: () => [...productKeys.all, 'list'] as const,
list: (filters: ProductFilters) => [...productKeys.lists(), filters] as const,
details: () => [...productKeys.all, 'detail'] as const,
detail: (id: string) => [...productKeys.details(), id] as const,
}
interface ProductFilters {
category?: string
search?: string
page?: number
}
// Fetch products list
export function useProducts(filters: MaybeRef<ProductFilters> = {}) {
return useQuery({
queryKey: computed(() => productKeys.list(toValue(filters))),
queryFn: async ({ queryKey }) => {
const [, , filterParams] = queryKey
const response = await api.get<{ data: Product[]; total: number }>('/products', {
params: filterParams
})
return response.data
},
staleTime: 5 * 60 * 1000, // Consider fresh for 5 minutes
})
}
// Fetch single product
export function useProduct(id: MaybeRef<string>) {
return useQuery({
queryKey: computed(() => productKeys.detail(toValue(id))),
queryFn: async () => {
const response = await api.get<Product>(`/products/${toValue(id)}`)
return response.data
},
enabled: computed(() => !!toValue(id)), // Only fetch if id exists
})
}
// Create product mutation
export function useCreateProduct() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (data: CreateProductDTO) => {
const response = await api.post<Product>('/products', data)
return response.data
},
onSuccess: (newProduct) => {
// Invalidate list queries to refetch
queryClient.invalidateQueries({ queryKey: productKeys.lists() })
// Optionally, add to cache directly
queryClient.setQueryData(
productKeys.detail(newProduct.id),
newProduct
)
}
})
}
// Update product mutation
export function useUpdateProduct() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ id, data }: { id: string; data: Partial<Product> }) => {
const response = await api.patch<Product>(`/products/${id}`, data)
return response.data
},
onMutate: async ({ id, data }) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: productKeys.detail(id) })
// Snapshot previous value
const previous = queryClient.getQueryData(productKeys.detail(id))
// Optimistically update
queryClient.setQueryData(productKeys.detail(id), (old: Product) => ({
...old,
...data
}))
return { previous }
},
onError: (err, { id }, context) => {
// Rollback on error
if (context?.previous) {
queryClient.setQueryData(productKeys.detail(id), context.previous)
}
},
onSettled: (data, error, { id }) => {
// Refetch after mutation
queryClient.invalidateQueries({ queryKey: productKeys.detail(id) })
}
})
}
// Delete mutation
export function useDeleteProduct() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (id: string) => {
await api.delete(`/products/${id}`)
},
onSuccess: (_, id) => {
// Remove from cache
queryClient.removeQueries({ queryKey: productKeys.detail(id) })
queryClient.invalidateQueries({ queryKey: productKeys.lists() })
}
})
}<!-- ProductList.vue - Using TanStack Query -->
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useProducts, useDeleteProduct } from '@/composables/useProducts'
const filters = ref({ category: '', search: '', page: 1 })
const {
data,
isLoading,
isError,
error,
isFetching, // True when refetching in background
refetch
} = useProducts(filters)
const products = computed(() => data.value?.data ?? [])
const total = computed(() => data.value?.total ?? 0)
const deleteMutation = useDeleteProduct()
const handleDelete = async (id: string) => {
if (confirm('Delete this product?')) {
await deleteMutation.mutateAsync(id)
}
}
</script>
<template>
<div>
<!-- Filters -->
<input v-model="filters.search" placeholder="Search...">
<select v-model="filters.category">
<option value="">All Categories</option>
<option value="electronics">Electronics</option>
</select>
<!-- Loading states -->
<div v-if="isLoading">Loading...</div>
<div v-else-if="isError">Error: {{ error?.message }}</div>
<template v-else>
<!-- Background refetch indicator -->
<span v-if="isFetching" class="refetching">Updating...</span>
<ul>
<li v-for="product in products" :key="product.id">
{{ product.name }} - ${{ product.price }}
<button
@click="handleDelete(product.id)"
:disabled="deleteMutation.isPending.value"
>
Delete
</button>
</li>
</ul>
<Pagination
:total="total"
v-model:page="filters.page"
/>
</template>
</div>
</template># 3.3 TypeScript Integration
<!-- GenericList.vue -->
<script setup lang="ts" generic="T extends { id: string | number }">
import { computed } from 'vue'
// Generic props
const props = defineProps<{
items: T[]
selected?: T | null
keyField?: keyof T
labelField?: keyof T
}>()
const emit = defineEmits<{
select: [item: T]
delete: [item: T]
}>()
// Generic slots with proper typing
defineSlots<{
default(props: { item: T; index: number }): any
empty(): any
header(): any
}>()
const getKey = (item: T) => item[props.keyField ?? 'id']
const getLabel = (item: T) => String(item[props.labelField ?? 'id'])
const isEmpty = computed(() => props.items.length === 0)
</script>
<template>
<div class="generic-list">
<div class="header">
<slot name="header" />
</div>
<ul v-if="!isEmpty">
<li
v-for="(item, index) in items"
:key="String(getKey(item))"
:class="{ selected: selected?.id === item.id }"
@click="emit('select', item)"
>
<slot :item="item" :index="index">
{{ getLabel(item) }}
</slot>
</li>
</ul>
<div v-else class="empty">
<slot name="empty">No items</slot>
</div>
</div>
</template><!-- Usage with full type inference -->
<script setup lang="ts">
interface User {
id: string
name: string
email: string
role: 'admin' | 'user'
}
const users = ref<User[]>([])
const selectedUser = ref<User | null>(null)
const handleSelect = (user: User) => { // Type inferred!
selectedUser.value = user
}
</script>
<template>
<GenericList
:items="users"
:selected="selectedUser"
label-field="name"
@select="handleSelect"
>
<template #default="{ item, index }">
<!-- item is typed as User -->
<span>{{ index + 1 }}. {{ item.name }} ({{ item.role }})</span>
</template>
</GenericList>
</template><script setup lang="ts">
interface FormData {
email: string
password: string
}
interface ValidationResult {
valid: boolean
errors: Record<string, string[]>
}
// Complex emit signatures
const emit = defineEmits<{
// Tuple syntax (Vue 3.3+)
submit: [data: FormData, event: Event]
validate: [result: ValidationResult]
// Function syntax (for complex overloads)
(e: 'update:modelValue', value: string): void
(e: 'change', value: string, previousValue: string): void
}>()
const handleSubmit = (event: Event) => {
event.preventDefault()
emit('submit', { email: '', password: '' }, event)
}
</script>// types/injection-keys.ts
import type { InjectionKey, Ref, ComputedRef } from 'vue'
import type { User, Theme } from './models'
// Define injection keys with full typing
export const UserKey: InjectionKey<Ref<User | null>> = Symbol('User')
export const ThemeKey: InjectionKey<Ref<Theme>> = Symbol('Theme')
export interface ToastContext {
show: (message: string, type?: 'success' | 'error' | 'info') => void
hide: (id: string) => void
}
export const ToastKey: InjectionKey<ToastContext> = Symbol('Toast')
// Type-safe provide/inject helpers
export function useToast(): ToastContext {
const toast = inject(ToastKey)
if (!toast) {
throw new Error('useToast must be used within a ToastProvider')
}
return toast
}<!-- Provider.vue -->
<script setup lang="ts">
import { provide, ref } from 'vue'
import { UserKey, ThemeKey, ToastKey } from '@/types/injection-keys'
const user = ref<User | null>(null)
const theme = ref<Theme>('light')
const toastContext: ToastContext = {
show: (message, type = 'info') => { /* ... */ },
hide: (id) => { /* ... */ }
}
// Type-checked provide
provide(UserKey, user) // ✅ Type matches
provide(ThemeKey, theme) // ✅ Type matches
provide(ToastKey, toastContext)
// provide(UserKey, 'string') // ❌ Type error!
</script>// types/utils.ts
import type { Ref, ComputedRef, UnwrapRef } from 'vue'
// Props that can be either value or ref
type MaybeRef<T> = T | Ref<T>
// Extract props type from component
type ExtractProps<T> = T extends new () => { $props: infer P } ? P : never
// Make certain props required
type RequireProps<T, K extends keyof T> = Omit<T, K> & Required<Pick<T, K>>
// Props with defaults removed from required
type PropsWithDefaults<T, D extends Partial<T>> = Omit<T, keyof D> & Partial<Pick<T, keyof D>>
// Async component data type
type AsyncData<T> = {
data: Ref<T | null>
pending: Ref<boolean>
error: Ref<Error | null>
refresh: () => Promise<void>
}
// Form field type helper
interface FormField<T> {
value: Ref<T>
error: Ref<string | null>
touched: Ref<boolean>
validate: () => boolean
reset: () => void
}# 3.4 Performance Optimization
import { ref, watch, watchEffect, watchPostEffect, watchSyncEffect } from 'vue'
const searchQuery = ref('')
const userId = ref<string | null>(null)
const filters = ref({ category: '', sort: 'name' })
// ═══════════════════════════════════════════════════════════
// watchEffect: Auto-tracks dependencies, runs immediately
// Use for: Side effects that depend on reactive state
// ═══════════════════════════════════════════════════════════
watchEffect(() => {
// Automatically tracks searchQuery
console.log(`Searching for: ${searchQuery.value}`)
// Cleanup function
return () => {
console.log('Cleanup previous effect')
}
})
// With async (onCleanup pattern)
watchEffect(async (onCleanup) => {
const controller = new AbortController()
onCleanup(() => controller.abort())
const results = await fetch(`/api/search?q=${searchQuery.value}`, {
signal: controller.signal
})
})
// ═══════════════════════════════════════════════════════════
// watch: Explicit dependencies, doesn't run immediately by default
// Use for: When you need old/new values, or conditional watching
// ═══════════════════════════════════════════════════════════
// Single source
watch(searchQuery, (newValue, oldValue) => {
console.log(`Changed from "${oldValue}" to "${newValue}"`)
}, { immediate: true }) // Run on mount
// Multiple sources
watch(
[searchQuery, filters],
([newQuery, newFilters], [oldQuery, oldFilters]) => {
console.log('Query or filters changed')
}
)
// Getter function (for nested properties)
watch(
() => filters.value.category,
(newCategory) => {
console.log('Category changed:', newCategory)
}
)
// Deep watching
watch(
filters,
(newFilters) => {
console.log('Any filter property changed')
},
{ deep: true } // Watches nested changes
)
// Once (Vue 3.4+)
watch(
userId,
(id) => {
if (id) analytics.identify(id)
},
{ once: true } // Only triggers once
)
// ═══════════════════════════════════════════════════════════
// Timing options
// ═══════════════════════════════════════════════════════════
// Default: pre (before DOM update)
watch(data, callback, { flush: 'pre' })
// After DOM update (when you need updated DOM)
watch(data, callback, { flush: 'post' })
// Or use watchPostEffect
watchPostEffect(() => {
// DOM is updated here
})
// Synchronous (use sparingly - can cause perf issues)
watch(data, callback, { flush: 'sync' })
watchSyncEffect(() => { /* ... */ })import { ref, computed, watch } from 'vue'
const items = ref<Item[]>([])
const filterTerm = ref('')
// ✅ COMPUTED: For derived/transformed data (cached)
const filteredItems = computed(() => {
console.log('Computing filtered items...') // Only logs when deps change
return items.value.filter(item =>
item.name.includes(filterTerm.value)
)
})
// Template uses filteredItems multiple times? Only computed once!
// <div>{{ filteredItems.length }} items</div>
// <ul><li v-for="item in filteredItems">...</li></ul>
// ❌ METHOD: Would recalculate on every render
const getFilteredItems = () => {
console.log('Method called...') // Logs on EVERY render
return items.value.filter(item =>
item.name.includes(filterTerm.value)
)
}
// ✅ WATCH: For side effects (API calls, localStorage, etc.)
watch(filteredItems, (newItems) => {
// Don't use computed for side effects!
analytics.track('filter_results', { count: newItems.length })
localStorage.setItem('lastFilter', filterTerm.value)
})// composables/useMemoize.ts
export function useMemoize<T extends (...args: any[]) => any>(
fn: T,
keyResolver?: (...args: Parameters<T>) => string
): T {
const cache = new Map<string, ReturnType<T>>()
return ((...args: Parameters<T>) => {
const key = keyResolver ? keyResolver(...args) : JSON.stringify(args)
if (cache.has(key)) {
return cache.get(key)!
}
const result = fn(...args)
cache.set(key, result)
return result
}) as T
}
// Usage
const expensiveCalculation = useMemoize((data: number[]) => {
return data.reduce((acc, val) => acc + Math.sqrt(val), 0)
})<!-- Using @tanstack/vue-virtual -->
<script setup lang="ts">
import { ref } from 'vue'
import { useVirtualizer } from '@tanstack/vue-virtual'
interface Item {
id: string
content: string
}
const props = defineProps<{
items: Item[]
}>()
const parentRef = ref<HTMLElement | null>(null)
const virtualizer = useVirtualizer({
count: props.items.length,
getScrollElement: () => parentRef.value,
estimateSize: () => 50, // Estimated row height
overscan: 5, // Render 5 extra items outside viewport
})
</script>
<template>
<div
ref="parentRef"
class="virtual-list"
:style="{ height: '400px', overflow: 'auto' }"
>
<div
:style="{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative'
}"
>
<div
v-for="virtualRow in virtualizer.getVirtualItems()"
:key="virtualRow.key"
:style="{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`
}"
>
{{ items[virtualRow.index].content }}
</div>
</div>
</div>
</template>import { defineAsyncComponent } from 'vue'
// Basic async component
const AsyncModal = defineAsyncComponent(() =>
import('./components/HeavyModal.vue')
)
// With loading/error states
const AsyncDashboard = defineAsyncComponent({
loader: () => import('./views/Dashboard.vue'),
loadingComponent: LoadingSpinner,
errorComponent: ErrorDisplay,
delay: 200, // Show loading after 200ms
timeout: 10000, // Error after 10s
suspensible: true, // Works with <Suspense>
onError(error, retry, fail, attempts) {
if (attempts <= 3) {
retry()
} else {
fail()
}
}
})
// Conditional loading
const showHeavyComponent = ref(false)
const HeavyComponent = computed(() =>
showHeavyComponent.value
? defineAsyncComponent(() => import('./HeavyComponent.vue'))
: null
)# 3.5 Error Boundaries and Logging
<!-- components/ErrorBoundary.vue -->
<script setup lang="ts">
import { ref, onErrorCaptured } from 'vue'
interface Props {
fallback?: string
onError?: (error: Error, info: string) => void
}
const props = withDefaults(defineProps<Props>(), {
fallback: 'Something went wrong'
})
const error = ref<Error | null>(null)
const errorInfo = ref<string>('')
onErrorCaptured((err, instance, info) => {
error.value = err instanceof Error ? err : new Error(String(err))
errorInfo.value = info
// Call optional handler
props.onError?.(error.value, info)
// Report to monitoring service
errorReporter.capture(error.value, {
componentName: instance?.$options?.name,
info
})
// Prevent propagation
return false
})
const reset = () => {
error.value = null
errorInfo.value = ''
}
</script>
<template>
<slot v-if="!error" />
<div v-else class="error-boundary">
<slot name="error" :error="error" :reset="reset">
<div class="error-fallback">
<p>{{ fallback }}</p>
<pre v-if="isDev">{{ error.message }}</pre>
<button @click="reset">Try Again</button>
</div>
</slot>
</div>
</template><!-- Usage -->
<template>
<ErrorBoundary @error="handleError">
<RiskyComponent />
<template #error="{ error, reset }">
<div class="custom-error">
<h3>Oops!</h3>
<p>{{ error.message }}</p>
<button @click="reset">Retry</button>
<button @click="reportIssue(error)">Report Issue</button>
</div>
</template>
</ErrorBoundary>
</template>// main.ts
import { createApp } from 'vue'
import * as Sentry from '@sentry/vue'
const app = createApp(App)
// Global error handler (uncaught errors)
app.config.errorHandler = (err, instance, info) => {
// Log to console in dev
console.error('Vue Error:', err)
console.log('Component:', instance)
console.log('Info:', info)
// Report to monitoring
Sentry.captureException(err, {
extra: {
componentName: instance?.$options?.name,
info
}
})
}
// Global warning handler (dev only)
app.config.warnHandler = (msg, instance, trace) => {
console.warn('Vue Warning:', msg)
console.log('Trace:', trace)
}
// Sentry integration
Sentry.init({
app,
dsn: import.meta.env.VITE_SENTRY_DSN,
integrations: [
Sentry.browserTracingIntegration({ router }),
Sentry.replayIntegration()
],
tracesSampleRate: 0.1,
replaysSessionSampleRate: 0.1,
replaysOnErrorSampleRate: 1.0
})
app.mount('#app')// composables/useLogger.ts
type LogLevel = 'debug' | 'info' | 'warn' | 'error'
interface LogEntry {
level: LogLevel
message: string
context?: Record<string, any>
timestamp: Date
component?: string
}
class Logger {
private static instance: Logger
private logs: LogEntry[] = []
private remoteEndpoint?: string
constructor(config?: { remoteEndpoint?: string }) {
this.remoteEndpoint = config?.remoteEndpoint
}
static getInstance(config?: { remoteEndpoint?: string }): Logger {
if (!Logger.instance) {
Logger.instance = new Logger(config)
}
return Logger.instance
}
private log(level: LogLevel, message: string, context?: Record<string, any>) {
const entry: LogEntry = {
level,
message,
context,
timestamp: new Date()
}
this.logs.push(entry)
// Console output in dev
if (import.meta.env.DEV) {
const method = level === 'error' ? 'error' : level === 'warn' ? 'warn' : 'log'
console[method](`[${level.toUpperCase()}]`, message, context ?? '')
}
// Remote logging for errors
if (level === 'error' && this.remoteEndpoint) {
this.sendToRemote(entry)
}
}
private async sendToRemote(entry: LogEntry) {
try {
await fetch(this.remoteEndpoint!, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(entry)
})
} catch {
console.warn('Failed to send log to remote')
}
}
debug = (msg: string, ctx?: Record<string, any>) => this.log('debug', msg, ctx)
info = (msg: string, ctx?: Record<string, any>) => this.log('info', msg, ctx)
warn = (msg: string, ctx?: Record<string, any>) => this.log('warn', msg, ctx)
error = (msg: string, ctx?: Record<string, any>) => this.log('error', msg, ctx)
getLogs = () => [...this.logs]
clear = () => { this.logs = [] }
}
export function useLogger(componentName?: string) {
const logger = Logger.getInstance({
remoteEndpoint: import.meta.env.VITE_LOG_ENDPOINT
})
return {
debug: (msg: string, ctx?: Record<string, any>) =>
logger.debug(msg, { ...ctx, component: componentName }),
info: (msg: string, ctx?: Record<string, any>) =>
logger.info(msg, { ...ctx, component: componentName }),
warn: (msg: string, ctx?: Record<string, any>) =>
logger.warn(msg, { ...ctx, component: componentName }),
error: (msg: string, ctx?: Record<string, any>) =>
logger.error(msg, { ...ctx, component: componentName }),
}
}# 3.6 Dependency Injection (provide/inject)
// Backend (e.g., NestJS)
@Injectable()
class OrderService {
constructor(
private readonly db: DatabaseService,
private readonly email: EmailService
) {}
}
// Vue equivalent
const OrderService = {
setup() {
const db = inject(DatabaseKey)!
const email = inject(EmailKey)!
return { /* ... */ }
}
}// lib/injection.ts
import { inject, provide, type InjectionKey } from 'vue'
// Create typed injection helper
export function createInjection<T>(
name: string,
defaultValue?: T
) {
const key: InjectionKey<T> = Symbol(name)
const provideValue = (value: T) => {
provide(key, value)
}
const injectValue = (fallback?: T): T => {
const value = inject(key, fallback ?? defaultValue)
if (value === undefined) {
throw new Error(`Injection "${name}" not provided`)
}
return value
}
return {
key,
provide: provideValue,
inject: injectValue
}
}
// Usage
export const AuthService = createInjection<{
user: Ref<User | null>
login: (credentials: Credentials) => Promise<void>
logout: () => void
}>('AuthService')
export const ThemeService = createInjection<{
theme: Ref<'light' | 'dark'>
toggle: () => void
}>('ThemeService')<!-- providers/AppProvider.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import { AuthService, ThemeService } from '@/lib/injection'
// Auth
const user = ref<User | null>(null)
const login = async (creds: Credentials) => { /* ... */ }
const logout = () => { user.value = null }
AuthService.provide({ user, login, logout })
// Theme
const theme = ref<'light' | 'dark'>('light')
const toggle = () => {
theme.value = theme.value === 'light' ? 'dark' : 'light'
}
ThemeService.provide({ theme, toggle })
</script>
<template>
<slot />
</template><!-- Any nested component -->
<script setup lang="ts">
import { AuthService, ThemeService } from '@/lib/injection'
// Type-safe injection
const { user, logout } = AuthService.inject()
const { theme, toggle } = ThemeService.inject()
</script>
<template>
<div :class="theme">
<span v-if="user">{{ user.name }}</span>
<button @click="toggle">Toggle Theme</button>
<button @click="logout">Logout</button>
</div>
</template>// plugins/services.ts
import type { App, Plugin } from 'vue'
interface ServicesPluginOptions {
apiBaseUrl: string
analyticsKey?: string
}
export const servicesPlugin: Plugin<ServicesPluginOptions> = {
install(app: App, options: ServicesPluginOptions) {
// Create service instances
const apiClient = new ApiClient(options.apiBaseUrl)
const analytics = options.analyticsKey
? new Analytics(options.analyticsKey)
: null
// Provide globally
app.provide('apiClient', apiClient)
app.provide('analytics', analytics)
// Also available via globalProperties (Options API compatibility)
app.config.globalProperties.$api = apiClient
}
}
// main.ts
import { servicesPlugin } from './plugins/services'
app.use(servicesPlugin, {
apiBaseUrl: import.meta.env.VITE_API_URL,
analyticsKey: import.meta.env.VITE_ANALYTICS_KEY
})# 4. Ecosystem Integration
# 4.1 Vue Router
// router/index.ts
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
// Define route meta types
declare module 'vue-router' {
interface RouteMeta {
requiresAuth?: boolean
roles?: string[]
title?: string
layout?: 'default' | 'auth' | 'blank'
}
}
const routes: RouteRecordRaw[] = [
{
path: '/',
name: 'home',
component: () => import('@/views/Home.vue'),
meta: { title: 'Home' }
},
{
path: '/dashboard',
name: 'dashboard',
component: () => import('@/views/Dashboard.vue'),
meta: {
requiresAuth: true,
roles: ['admin', 'user'],
title: 'Dashboard'
}
},
{
path: '/users/:id',
name: 'user-detail',
component: () => import('@/views/UserDetail.vue'),
props: true, // Pass :id as prop
meta: { requiresAuth: true }
},
// Nested routes
{
path: '/settings',
component: () => import('@/views/SettingsLayout.vue'),
children: [
{
path: '',
name: 'settings',
redirect: 'profile'
},
{
path: 'profile',
name: 'settings-profile',
component: () => import('@/views/settings/Profile.vue')
},
{
path: 'security',
name: 'settings-security',
component: () => import('@/views/settings/Security.vue')
}
]
},
// Catch-all 404
{
path: '/:pathMatch(.*)*',
name: 'not-found',
component: () => import('@/views/NotFound.vue')
}
]
export const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes,
scrollBehavior(to, from, savedPosition) {
if (savedPosition) {
return savedPosition
}
if (to.hash) {
return { el: to.hash, behavior: 'smooth' }
}
return { top: 0 }
}
})// router/guards.ts
import type { Router } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
export function setupGuards(router: Router) {
// Global before guard (like middleware)
router.beforeEach(async (to, from) => {
const auth = useAuthStore()
// Update document title
document.title = to.meta.title
? `${to.meta.title} | MyApp`
: 'MyApp'
// Auth check
if (to.meta.requiresAuth && !auth.isAuthenticated) {
// Store intended destination
return {
name: 'login',
query: { redirect: to.fullPath }
}
}
// Role check
if (to.meta.roles && !to.meta.roles.includes(auth.user?.role ?? '')) {
return { name: 'forbidden' }
}
// Continue navigation
return true
})
// After navigation
router.afterEach((to, from, failure) => {
if (failure) {
console.error('Navigation failed:', failure)
return
}
// Analytics tracking
analytics.pageView(to.fullPath)
})
// Error handling
router.onError((error) => {
console.error('Router error:', error)
// Handle chunk loading errors (lazy loading failed)
if (error.message.includes('Failed to fetch dynamically imported module')) {
window.location.reload()
}
})
}
// Per-route guards (in route definition)
const routes = [
{
path: '/admin',
component: AdminLayout,
beforeEnter: (to, from) => {
// Route-specific guard
const auth = useAuthStore()
if (auth.user?.role !== 'admin') {
return { name: 'forbidden' }
}
}
}
]<script setup lang="ts">
import { useRouter, useRoute } from 'vue-router'
const router = useRouter()
const route = useRoute()
// Current route info
console.log(route.params.id) // URL params
console.log(route.query.search) // Query string
console.log(route.meta.title) // Meta data
console.log(route.name) // Route name
// Navigation methods
const navigateToUser = async (userId: string) => {
// By name (recommended - type-safe with typed routes)
await router.push({ name: 'user-detail', params: { id: userId } })
// By path
await router.push(`/users/${userId}`)
// With query params
await router.push({
name: 'users',
query: { page: 1, sort: 'name' }
})
// Replace (no history entry)
await router.replace({ name: 'home' })
// Go back/forward
router.back()
router.forward()
router.go(-2) // Go back 2 steps
}
// Navigation with result handling
const safeNavigate = async () => {
const result = await router.push({ name: 'dashboard' })
if (result) {
// Navigation was prevented or failed
console.error('Navigation failed:', result)
}
}
</script>// Advanced lazy loading patterns
const routes = [
{
path: '/reports',
component: () => import('@/views/Reports.vue'),
// Webpack magic comments (Vite also supports some)
// component: () => import(/* webpackChunkName: "reports" */ '@/views/Reports.vue'),
},
// Prefetch on hover/visibility
{
path: '/heavy-feature',
component: defineAsyncComponent({
loader: () => import('@/views/HeavyFeature.vue'),
loadingComponent: LoadingSpinner,
delay: 200,
errorComponent: ErrorDisplay,
timeout: 10000
})
}
]
// Prefetch utilities
export function usePrefetch() {
const prefetchRoute = (name: string) => {
const route = router.getRoutes().find(r => r.name === name)
if (route?.components?.default && typeof route.components.default === 'function') {
;(route.components.default as () => Promise<any>)()
}
}
return { prefetchRoute }
}# 4.2 API Integration: TanStack Query vs Axios
// lib/api.ts
import axios, { AxiosError, type AxiosInstance, type InternalAxiosRequestConfig } from 'axios'
import { useAuthStore } from '@/stores/auth'
import { router } from '@/router'
// Create instance
export const api: AxiosInstance = axios.create({
baseURL: import.meta.env.VITE_API_URL,
timeout: 30000,
headers: {
'Content-Type': 'application/json'
}
})
// Request interceptor
api.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const auth = useAuthStore()
// Attach auth token
if (auth.token) {
config.headers.Authorization = `Bearer ${auth.token}`
}
// Add request ID for tracing
config.headers['X-Request-ID'] = crypto.randomUUID()
return config
},
(error) => {
return Promise.reject(error)
}
)
// Response interceptor
api.interceptors.response.use(
(response) => {
// Transform response if needed
return response
},
async (error: AxiosError) => {
const originalRequest = error.config
// Handle 401 - Token expired
if (error.response?.status === 401 && originalRequest) {
const auth = useAuthStore()
try {
// Attempt token refresh
await auth.refreshToken()
// Retry original request
return api(originalRequest)
} catch (refreshError) {
// Refresh failed - logout and redirect
auth.logout()
router.push({ name: 'login' })
return Promise.reject(refreshError)
}
}
// Handle other errors
if (error.response?.status === 403) {
router.push({ name: 'forbidden' })
}
if (error.response?.status === 500) {
// Log to monitoring
console.error('Server error:', error.response.data)
}
return Promise.reject(error)
}
)
// Type-safe API methods
export interface ApiResponse<T> {
data: T
meta?: {
total: number
page: number
pageSize: number
}
}
export async function get<T>(url: string, params?: Record<string, any>): Promise<T> {
const response = await api.get<T>(url, { params })
return response.data
}
export async function post<T>(url: string, data?: unknown): Promise<T> {
const response = await api.post<T>(url, data)
return response.data
}
export async function put<T>(url: string, data?: unknown): Promise<T> {
const response = await api.put<T>(url, data)
return response.data
}
export async function del<T = void>(url: string): Promise<T> {
const response = await api.delete<T>(url)
return response.data
}// lib/query.ts
import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query'
import type { App } from 'vue'
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 30 * 60 * 1000, // 30 minutes (formerly cacheTime)
retry: (failureCount, error) => {
// Don't retry on 4xx errors
if (error instanceof Error && 'status' in error) {
const status = (error as any).status
if (status >= 400 && status < 500) return false
}
return failureCount < 3
},
refetchOnWindowFocus: import.meta.env.PROD, // Only in production
refetchOnReconnect: true,
},
mutations: {
retry: 1,
onError: (error) => {
// Global mutation error handling
console.error('Mutation error:', error)
}
}
}
})
// Plugin setup
export function setupQuery(app: App) {
app.use(VueQueryPlugin, {
queryClient,
enableDevtoolsV6Plugin: true
})
}// composables/api/useUsers.ts
import { useQuery, useMutation, useQueryClient, useInfiniteQuery } from '@tanstack/vue-query'
import { api, type ApiResponse } from '@/lib/api'
import type { User, CreateUserDTO, UpdateUserDTO } from '@/types'
// Query key factory
export const userKeys = {
all: ['users'] as const,
lists: () => [...userKeys.all, 'list'] as const,
list: (filters?: UserFilters) => [...userKeys.lists(), filters] as const,
infinite: (filters?: UserFilters) => [...userKeys.lists(), 'infinite', filters] as const,
details: () => [...userKeys.all, 'detail'] as const,
detail: (id: string) => [...userKeys.details(), id] as const,
}
interface UserFilters {
search?: string
role?: string
status?: 'active' | 'inactive'
page?: number
pageSize?: number
}
// List users
export function useUsers(filters?: MaybeRef<UserFilters>) {
return useQuery({
queryKey: computed(() => userKeys.list(toValue(filters))),
queryFn: async () => {
return api.get<ApiResponse<User[]>>('/users', { params: toValue(filters) })
},
placeholderData: (previousData) => previousData // Keep previous data while fetching
})
}
// Infinite scroll users
export function useInfiniteUsers(filters?: MaybeRef<Omit<UserFilters, 'page'>>) {
return useInfiniteQuery({
queryKey: computed(() => userKeys.infinite(toValue(filters))),
queryFn: async ({ pageParam = 1 }) => {
return api.get<ApiResponse<User[]>>('/users', {
params: { ...toValue(filters), page: pageParam }
})
},
initialPageParam: 1,
getNextPageParam: (lastPage, allPages) => {
const currentCount = allPages.flatMap(p => p.data).length
if (currentCount < (lastPage.meta?.total ?? 0)) {
return allPages.length + 1
}
return undefined
}
})
}
// Single user
export function useUser(id: MaybeRef<string>) {
return useQuery({
queryKey: computed(() => userKeys.detail(toValue(id))),
queryFn: async () => {
return api.get<User>(`/users/${toValue(id)}`)
},
enabled: computed(() => !!toValue(id))
})
}
// Create user
export function useCreateUser() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: CreateUserDTO) =>
api.post<User>('/users', data),
onSuccess: (newUser) => {
// Add to cache
queryClient.setQueryData(userKeys.detail(newUser.id), newUser)
// Invalidate lists
queryClient.invalidateQueries({ queryKey: userKeys.lists() })
}
})
}
// Update user
export function useUpdateUser() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateUserDTO }) =>
api.put<User>(`/users/${id}`, data),
// Optimistic update
onMutate: async ({ id, data }) => {
await queryClient.cancelQueries({ queryKey: userKeys.detail(id) })
const previous = queryClient.getQueryData<User>(userKeys.detail(id))
queryClient.setQueryData<User>(userKeys.detail(id), (old) =>
old ? { ...old, ...data } : undefined
)
return { previous, id }
},
onError: (error, variables, context) => {
if (context?.previous) {
queryClient.setQueryData(userKeys.detail(context.id), context.previous)
}
},
onSettled: (data, error, { id }) => {
queryClient.invalidateQueries({ queryKey: userKeys.detail(id) })
queryClient.invalidateQueries({ queryKey: userKeys.lists() })
}
})
}
// Delete user
export function useDeleteUser() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: string) => api.delete(`/users/${id}`),
onSuccess: (_, id) => {
queryClient.removeQueries({ queryKey: userKeys.detail(id) })
queryClient.invalidateQueries({ queryKey: userKeys.lists() })
}
})
}# 4.3 Form Handling
// schemas/user.ts
import { z } from 'zod'
import { toTypedSchema } from '@vee-validate/zod'
// Define schema with Zod
export const userSchema = z.object({
email: z
.string()
.min(1, 'Email is required')
.email('Invalid email format'),
password: z
.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Password must contain an uppercase letter')
.regex(/[0-9]/, 'Password must contain a number'),
confirmPassword: z.string(),
name: z
.string()
.min(2, 'Name must be at least 2 characters')
.max(50, 'Name must be less than 50 characters'),
age: z
.number({ invalid_type_error: 'Age must be a number' })
.min(18, 'Must be at least 18')
.max(120, 'Invalid age')
.optional(),
role: z.enum(['admin', 'user', 'guest']),
acceptTerms: z.literal(true, {
errorMap: () => ({ message: 'You must accept the terms' })
})
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ['confirmPassword']
})
// Convert to VeeValidate schema
export const userValidationSchema = toTypedSchema(userSchema)
// TypeScript type from schema
export type UserFormData = z.infer<typeof userSchema><!-- components/UserForm.vue -->
<script setup lang="ts">
import { useForm, useField } from 'vee-validate'
import { userValidationSchema, type UserFormData } from '@/schemas/user'
const emit = defineEmits<{
submit: [data: UserFormData]
}>()
// Form setup
const { handleSubmit, errors, isSubmitting, meta, resetForm, setFieldError } = useForm({
validationSchema: userValidationSchema,
initialValues: {
email: '',
password: '',
confirmPassword: '',
name: '',
role: 'user' as const,
acceptTerms: false as const
}
})
// Individual fields with useField
const { value: email, errorMessage: emailError } = useField<string>('email')
const { value: password, errorMessage: passwordError } = useField<string>('password')
const { value: confirmPassword, errorMessage: confirmPasswordError } = useField<string>('confirmPassword')
const { value: name, errorMessage: nameError } = useField<string>('name')
const { value: age, errorMessage: ageError } = useField<number | undefined>('age')
const { value: role } = useField<string>('role')
const { value: acceptTerms, errorMessage: acceptTermsError } = useField<boolean>('acceptTerms')
// Submit handler
const onSubmit = handleSubmit(async (values) => {
try {
emit('submit', values)
} catch (error) {
// Handle server-side validation errors
if (error instanceof ApiError && error.errors) {
Object.entries(error.errors).forEach(([field, message]) => {
setFieldError(field as keyof UserFormData, message as string)
})
}
}
})
</script>
<template>
<form @submit="onSubmit" class="form">
<div class="field">
<label for="email">Email</label>
<input
id="email"
v-model="email"
type="email"
:class="{ 'error': emailError }"
>
<span v-if="emailError" class="error-message">{{ emailError }}</span>
</div>
<div class="field">
<label for="password">Password</label>
<input
id="password"
v-model="password"
type="password"
:class="{ 'error': passwordError }"
>
<span v-if="passwordError" class="error-message">{{ passwordError }}</span>
</div>
<div class="field">
<label for="confirmPassword">Confirm Password</label>
<input
id="confirmPassword"
v-model="confirmPassword"
type="password"
:class="{ 'error': confirmPasswordError }"
>
<span v-if="confirmPasswordError" class="error-message">{{ confirmPasswordError }}</span>
</div>
<div class="field">
<label for="name">Name</label>
<input id="name" v-model="name" type="text">
<span v-if="nameError" class="error-message">{{ nameError }}</span>
</div>
<div class="field">
<label for="age">Age (optional)</label>
<input id="age" v-model.number="age" type="number">
<span v-if="ageError" class="error-message">{{ ageError }}</span>
</div>
<div class="field">
<label for="role">Role</label>
<select id="role" v-model="role">
<option value="user">User</option>
<option value="admin">Admin</option>
<option value="guest">Guest</option>
</select>
</div>
<div class="field checkbox">
<input id="acceptTerms" v-model="acceptTerms" type="checkbox">
<label for="acceptTerms">I accept the terms and conditions</label>
<span v-if="acceptTermsError" class="error-message">{{ acceptTermsError }}</span>
</div>
<button
type="submit"
:disabled="isSubmitting || !meta.valid"
>
{{ isSubmitting ? 'Submitting...' : 'Submit' }}
</button>
</form>
</template><!-- components/FormField.vue -->
<script setup lang="ts">
import { useField } from 'vee-validate'
import { computed } from 'vue'
interface Props {
name: string
label: string
type?: string
placeholder?: string
rules?: string
}
const props = withDefaults(defineProps<Props>(), {
type: 'text',
placeholder: ''
})
const { value, errorMessage, meta } = useField<string>(() => props.name)
const showError = computed(() => errorMessage.value && meta.touched)
</script>
<template>
<div class="form-field" :class="{ 'has-error': showError }">
<label :for="name">{{ label }}</label>
<input
:id="name"
v-model="value"
:type="type"
:placeholder="placeholder"
:aria-invalid="showError"
:aria-describedby="showError ? `${name}-error` : undefined"
>
<span v-if="showError" :id="`${name}-error`" class="error" role="alert">
{{ errorMessage }}
</span>
</div>
</template># 4.4 Testing Patterns
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath } from 'node:url'
export default defineConfig({
plugins: [vue()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./tests/setup.ts'],
include: ['**/*.{test,spec}.{js,ts,vue}'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: ['node_modules/', 'tests/']
}
},
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
}
})// tests/setup.ts
import { config } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import { vi } from 'vitest'
// Global mocks
vi.mock('vue-router', async () => {
const actual = await vi.importActual('vue-router')
return {
...actual,
useRouter: vi.fn(() => ({
push: vi.fn(),
replace: vi.fn(),
back: vi.fn()
})),
useRoute: vi.fn(() => ({
params: {},
query: {},
meta: {}
}))
}
})
// Global stubs
config.global.stubs = {
teleport: true,
transition: false
}
// Reset mocks between tests
beforeEach(() => {
vi.clearAllMocks()
})// components/__tests__/UserCard.spec.ts
import { describe, it, expect, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import UserCard from '../UserCard.vue'
import { useUserStore } from '@/stores/user'
describe('UserCard', () => {
const defaultProps = {
user: {
id: '1',
name: 'John Doe',
email: '[email protected]',
role: 'admin' as const
}
}
const createWrapper = (props = defaultProps, options = {}) => {
return mount(UserCard, {
props,
global: {
plugins: [
createTestingPinia({
createSpy: vi.fn,
stubActions: false // Set to true to auto-stub all actions
})
],
...options
}
})
}
it('renders user name and email', () => {
const wrapper = createWrapper()
expect(wrapper.text()).toContain('John Doe')
expect(wrapper.text()).toContain('[email protected]')
})
it('displays admin badge for admin users', () => {
const wrapper = createWrapper()
expect(wrapper.find('.admin-badge').exists()).toBe(true)
})
it('hides admin badge for non-admin users', () => {
const wrapper = createWrapper({
user: { ...defaultProps.user, role: 'user' }
})
expect(wrapper.find('.admin-badge').exists()).toBe(false)
})
it('emits edit event when edit button is clicked', async () => {
const wrapper = createWrapper()
await wrapper.find('[data-testid="edit-button"]').trigger('click')
expect(wrapper.emitted('edit')).toBeTruthy()
expect(wrapper.emitted('edit')![0]).toEqual([defaultProps.user])
})
it('shows confirmation dialog before delete', async () => {
const wrapper = createWrapper()
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
await wrapper.find('[data-testid="delete-button"]').trigger('click')
expect(confirmSpy).toHaveBeenCalledWith('Delete this user?')
expect(wrapper.emitted('delete')).toBeTruthy()
})
it('does not emit delete when confirmation is cancelled', async () => {
const wrapper = createWrapper()
vi.spyOn(window, 'confirm').mockReturnValue(false)
await wrapper.find('[data-testid="delete-button"]').trigger('click')
expect(wrapper.emitted('delete')).toBeFalsy()
})
})// composables/__tests__/useCounter.spec.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { useCounter } from '../useCounter'
describe('useCounter', () => {
it('initializes with default value', () => {
const { count } = useCounter()
expect(count.value).toBe(0)
})
it('initializes with custom value', () => {
const { count } = useCounter({ initialValue: 10 })
expect(count.value).toBe(10)
})
it('increments count', () => {
const { count, increment } = useCounter()
increment()
expect(count.value).toBe(1)
increment()
expect(count.value).toBe(2)
})
it('respects max value', () => {
const { count, increment } = useCounter({ initialValue: 9, max: 10 })
increment()
expect(count.value).toBe(10)
increment()
expect(count.value).toBe(10) // Stays at max
})
it('computes doubled value', () => {
const { count, doubled, increment } = useCounter()
expect(doubled.value).toBe(0)
increment()
expect(doubled.value).toBe(2)
})
})// composables/__tests__/useUsers.spec.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query'
import { mount } from '@vue/test-utils'
import { defineComponent, h } from 'vue'
import { useUsers } from '../useUsers'
import * as api from '@/lib/api'
vi.mock('@/lib/api')
describe('useUsers', () => {
let queryClient: QueryClient
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: 0
}
}
})
vi.clearAllMocks()
})
const createTestComponent = (composableFn: () => any) => {
let result: any
const TestComponent = defineComponent({
setup() {
result = composableFn()
return () => h('div')
}
})
mount(TestComponent, {
global: {
plugins: [[VueQueryPlugin, { queryClient }]]
}
})
return { result }
}
it('fetches users successfully', async () => {
const mockUsers = [
{ id: '1', name: 'User 1' },
{ id: '2', name: 'User 2' }
]
vi.mocked(api.get).mockResolvedValue({ data: mockUsers, meta: { total: 2 } })
const { result } = createTestComponent(() => useUsers())
expect(result.isLoading.value).toBe(true)
await flushPromises()
expect(result.isLoading.value).toBe(false)
expect(result.data.value?.data).toEqual(mockUsers)
})
it('handles error state', async () => {
vi.mocked(api.get).mockRejectedValue(new Error('Network error'))
const { result } = createTestComponent(() => useUsers())
await flushPromises()
expect(result.isError.value).toBe(true)
expect(result.error.value?.message).toBe('Network error')
})
})# 4.5 DevTools and Debugging
// main.ts - Enable devtools in production (if needed)
const app = createApp(App)
// Vue 3 devtools are automatically enabled in dev
// For production debugging (use sparingly):
if (import.meta.env.VITE_ENABLE_DEVTOOLS === 'true') {
app.config.devtools = true
}// plugins/devtools.ts
import { setupDevtoolsPlugin, type DevtoolsPluginApi } from '@vue/devtools-api'
import type { App } from 'vue'
export function setupCustomDevtools(app: App) {
setupDevtoolsPlugin(
{
id: 'my-app-devtools',
label: 'My App DevTools',
packageName: 'my-app',
homepage: 'https://myapp.com',
app
},
(api: DevtoolsPluginApi<{}>) => {
// Add custom inspector
api.addInspector({
id: 'my-app-state',
label: 'App State',
icon: 'storage'
})
api.on.getInspectorTree((payload) => {
if (payload.inspectorId === 'my-app-state') {
payload.rootNodes = [
{
id: 'stores',
label: 'Pinia Stores',
children: [
{ id: 'user-store', label: 'User Store' },
{ id: 'cart-store', label: 'Cart Store' }
]
}
]
}
})
// Add timeline events
api.addTimelineLayer({
id: 'my-app-events',
label: 'App Events',
color: 0x41b883
})
// Log custom events
;(window as any).__logEvent = (label: string, data: any) => {
api.addTimelineEvent({
layerId: 'my-app-events',
event: {
time: Date.now(),
title: label,
data
}
})
}
}
)
}// utils/debug.ts
import { watch, type WatchSource, type Ref } from 'vue'
// Log reactive value changes
export function useDebugWatch<T>(
source: WatchSource<T>,
name: string
) {
if (import.meta.env.DEV) {
watch(source, (newVal, oldVal) => {
console.group(`🔄 ${name} changed`)
console.log('Old:', oldVal)
console.log('New:', newVal)
console.groupEnd()
}, { deep: true })
}
}
// Component render tracker
export function useRenderTracker(componentName: string) {
if (import.meta.env.DEV) {
let renderCount = 0
return () => {
renderCount++
console.log(`🎨 ${componentName} rendered (${renderCount} times)`)
}
}
return () => {}
}
// Performance measurement
export function usePerfMark(name: string) {
const start = () => performance.mark(`${name}-start`)
const end = () => {
performance.mark(`${name}-end`)
performance.measure(name, `${name}-start`, `${name}-end`)
const entries = performance.getEntriesByName(name)
console.log(`⏱️ ${name}: ${entries[entries.length - 1].duration.toFixed(2)}ms`)
}
return { start, end }
}
// Debug component in template
export const DebugRef = defineComponent({
props: {
data: { required: true },
label: { type: String, default: 'Debug' }
},
setup(props) {
return () => import.meta.env.DEV
? h('pre', { class: 'debug-output' },
`${props.label}: ${JSON.stringify(props.data, null, 2)}`
)
: null
}
})<script setup lang="ts">
import { onRenderTracked, onRenderTriggered } from 'vue'
// Track what dependencies the component has
onRenderTracked((event) => {
console.log('Render tracked:', event)
// { effect, target, type, key }
})
// Track what caused a re-render
onRenderTriggered((event) => {
console.log('Render triggered:', event)
// { effect, target, type, key, newValue, oldValue }
})
</script>// plugins/errorTracking.ts
import * as Sentry from '@sentry/vue'
import type { App, ComponentPublicInstance } from 'vue'
import { router } from '@/router'
export function setupErrorTracking(app: App) {
Sentry.init({
app,
dsn: import.meta.env.VITE_SENTRY_DSN,
integrations: [
Sentry.browserTracingIntegration({ router }),
Sentry.replayIntegration({
maskAllText: false,
blockAllMedia: false
})
],
// Performance
tracesSampleRate: import.meta.env.PROD ? 0.1 : 1.0,
// Session Replay
replaysSessionSampleRate: 0.1,
replaysOnErrorSampleRate: 1.0,
// Environment
environment: import.meta.env.MODE,
// Before sending
beforeSend(event, hint) {
// Filter or modify events
if (event.exception) {
console.error('Sending to Sentry:', event.exception)
}
return event
}
})
// Custom Vue error handler integration
const originalErrorHandler = app.config.errorHandler
app.config.errorHandler = (err, instance, info) => {
Sentry.captureException(err, {
extra: {
componentName: instance?.$options?.name,
info,
propsData: instance?.$props
}
})
// Call original handler
originalErrorHandler?.(err, instance, info)
}
}
// Usage: Capture custom errors
export function captureError(error: Error, context?: Record<string, any>) {
Sentry.captureException(error, { extra: context })
}
// Usage: Add breadcrumbs
export function addBreadcrumb(message: string, data?: Record<string, any>) {
Sentry.addBreadcrumb({
message,
data,
level: 'info'
})
}| Task | Solution |
|---|---|
| Local UI state | ref() / reactive() |
| Computed/derived data | computed() |
| Side effects on change | watch() / watchEffect() |
| Shared logic | Composables (use*.ts) |
| Global app state | Pinia stores |
| Server data caching | TanStack Query |
| Avoid prop drilling | provide/inject or composables |
| Form validation | VeeValidate + Zod |
| Routing | Vue Router with guards |
| Testing | Vitest + Vue Test Utils |
| Error tracking | Sentry |
| DevTools | Vue DevTools browser extension |