Vue 3 Master Reference for Backend Developers (2025 Edition) #
- 1. MENTAL MODEL SHIFT
- 2. SYNTAX & CORE CONCEPTS
- 3. MODERN PATTERNS & BEST PRACTICES
- 4. ECOSYSTEM INTEGRATION
1. MENTAL MODEL SHIFT #
1.1 Vue’s reactivity vs backend state management #
Backend mental model
- State is usually:
- Short‑lived (per request/job), or
- Long‑lived but centralized (DB, cache, in‑process singletons/actors).
- You pull state, compute, return a response.
- No automatic “UI update” – HTML is rendered once per response.
Vue mental model
- Your component tree is always “alive” in the browser.
- Vue tracks which DOM fragments depend on which reactive values.
- When reactive state changes, Vue:
- Schedules an update in the next tick.
- Re-runs the render function for affected components.
- Patches only the minimal DOM that changed.
Reactivity mechanics (simplified)
- Vue wraps your state in reactive containers:
ref()for single values.reactive()for objects/arrays.computed()for derived values.
- When a render function or
computed()reads reactive state, Vue records that dependency. - When that state changes, Vue:
- Marks dependents as “dirty”.
- Re-runs them in a batched, efficient way.
This is closer to:
- DB change → invalidate cache entries → recompute only dependent cached values
than to:
- “Render HTML template once per request”.
Key shift: you don’t imperatively re-render. You declare:
- what the state is, and
- how the UI depends on it,
and Vue handles propagation.
1.2 Component thinking vs backend service architecture #
Backend services
- You design modules/microservices around domains:
UserService,OrderService,AuthService.
- They expose methods; they don’t render UI.
- Typically stateless per request.
Vue components
- Each component is:
- A small UI unit (markup + styling + behavior).
- With inputs (props), outputs (emits), and internal state.
- Form a tree:
App→Layout→Page→Widget→Button…
- Data flow:
- Down: props (like function parameters / DTOs).
- Up: events (
emit) (like callbacks or messages on a bus).
Where do “services” live?
- Logic that is not inherently visual (API calls, business rules) shouldn’t stay inside components.
- Factor that logic into composables (see §2.6):
useAuth(),useUser(),useCart().
- Composables ≈ application/domain services in backend terms.
So:
- Components = small, nested controller+view units.
- Composables = services.
- Stores (Pinia) = in-memory domain aggregates / singletons.
1.3 Template syntax as compiled render functions #
Templates are not HTML with magic attributes. They’re a DSL that compiles to render functions.
Example:
<template>
<button @click="count++">{{ count }}</button>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const count = ref(0)
</script>Roughly compiles to:
import { createElementVNode, toDisplayString, openBlock, createElementBlock } from 'vue'
export function render(_ctx: any) {
return (openBlock(), createElementBlock(
"button",
{ onClick: () => _ctx.count++ },
toDisplayString(_ctx.count)
))
}Implications:
- Templates are declarative, but under the hood they’re just JS functions.
- Reading reactive values in templates automatically registers dependencies.
- The compiler:
- Separates static vs dynamic parts.
- Minimizes DOM work similarly to a carefully hand-written view layer.
2. SYNTAX & CORE CONCEPTS #
2.1 Composition API vs Options API #
Options API (older style)
import { defineComponent } from 'vue'
export default defineComponent({
data() {
return {
count: 0
}
},
computed: {
double() {
return this.count * 2
}
},
methods: {
increment() {
this.count++
}
},
mounted() {
console.log('Mounted')
}
})- Logic is grouped by option type (
data,methods,computed,watch, etc.). - Easy onboarding; harder to maintain for large features (related logic is spread across options).
Composition API (modern, recommended)
With <script setup>:
<template>
<button @click="increment">
{{ count }} (double: {{ double }})
</button>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
const count = ref(0)
const double = computed(() => count.value * 2)
function increment() {
count.value++
}
onMounted(() => {
console.log('Mounted')
})
</script>- Logic is grouped by feature, not by option type.
- Easy to extract into composables (
useCounter). - Superior TypeScript experience.
- Recommended default for new projects.
When to use which
- New codebase: Composition API +
<script setup>almost everywhere. - Legacy / small throwaway components: Options API is fine.
- Migration path: Start with Options for existing components; migrate heavy logic into composables, then to
<script setup>over time.
2.2 ref() vs reactive() vs computed() (TypeScript) #
ref<T>() – single reactive value #
- Wraps a primitive or object in
{ value: T }. - In templates,
.valueis auto-unwrapped.
import { ref } from 'vue'
const count = ref<number>(0)
count.value++ // OK
const name = ref<string | null>(null)
name.value = 'Alice'Template:
<template>
<p>{{ count }}</p> <!-- .value auto-unwrapped -->
</template>reactive<T>() – reactive objects/collections #
- Wraps an object/array in a Proxy.
- No
.value; you mutate like a normal object.
import { reactive } from 'vue'
interface User {
id: number
name: string
roles: string[]
}
const user = reactive<User>({
id: 1,
name: 'Alice',
roles: ['admin']
})
user.name = 'Bob' // reactive
user.roles.push('editor') // reactiveCaveats:
- Deep reactivity: nested objects are reactive too.
- For reuse, prefer smaller
refs +computedover sprawlingreactivebags.
ref() vs reactive() for objects #
Both patterns work:
// 1. ref of object
const user = ref<User | null>(null)
user.value = { id: 1, name: 'Alice', roles: [] }
// 2. reactive object
const user2 = reactive<User>({
id: 0,
name: '',
roles: []
})Guidelines:
ref:- Great for primitives or optional objects (e.g.
User | null). - More explicit for replacing the whole reference.
- Great for primitives or optional objects (e.g.
reactive:- Ergonomic for complex, nested objects (forms, config objects).
- For arrays: choose either
ref<T[]>()orreactive<T[]>([])and be consistent.
computed<T>() – derived reactive values #
- Like a cached getter that recomputes when dependencies change.
import { ref, computed } from 'vue'
const firstName = ref('Alice')
const lastName = ref('Smith')
const fullName = computed<string>(() => {
return `${firstName.value} ${lastName.value}`
})
console.log(fullName.value)Guidelines:
- Use
computedfor pure, synchronous derivations. - Don’t put side effects (API calls, logging) inside
computed. For that usewatch/watchEffect.
2.3 Lifecycle hooks mapped to backend equivalents #
Key Composition API hooks:
onBeforeMount– before original mount.onMounted– after first render is in DOM.onBeforeUpdate– before a reactive update patches DOM.onUpdated– after DOM patch.onBeforeUnmount– before component is removed.onUnmounted– after component is removed.onErrorCaptured– when a child throws an error.onActivated/onDeactivated– for<keep-alive>cached components.
Backend analogies
onMounted:- Like “controller initialized, first response done”.
- Use it for initial data fetch, subscriptions, DOM integrations.
onUnmounted:- Like disposing a handler / background worker.
- Use it to cleanup: unsubscribe, remove listeners, stop timers.
onUpdated:- Like “view re-rendered”; use sparingly (only for DOM libraries that need it).
Example:
import { ref, onMounted, onUnmounted } from 'vue'
function useWindowSize() {
const width = ref(window.innerWidth)
const height = ref(window.innerHeight)
const onResize = () => {
width.value = window.innerWidth
height.value = window.innerHeight
}
onMounted(() => {
window.addEventListener('resize', onResize)
})
onUnmounted(() => {
window.removeEventListener('resize', onResize)
})
return { width, height }
}2.4 Template directives: v-if, v-for, v-model #
v-if / v-else-if / v-else #
- Conditional rendering: element may or may not exist in DOM.
<div v-if="user">
Hello, {{ user.name }}
</div>
<div v-else>
Not logged in
</div>v-showis an alternative that togglesdisplay: nonebut keeps the element in DOM.
v-for #
- Loops over arrays/objects.
<li v-for="user in users" :key="user.id">
{{ user.name }}
</li>
<li v-for="(value, key, index) in someObject" :key="key">
{{ index }} - {{ key }}: {{ value }}
</li>Always provide a stable :key for efficient diffing.
v-model #
On native inputs:
<input v-model="name" />
<!-- modifiers -->
<input v-model.trim="name" />
<input v-model.number="age" />
<input v-model.lazy="searchTerm" /> <!-- updates on change, not on every input -->On components (default modelValue):
Child:
<!-- MyInput.vue -->
<template>
<input :value="modelValue" @input="onInput" />
</template>
<script setup lang="ts">
const props = defineProps<{
modelValue: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
function onInput(event: Event) {
emit('update:modelValue', (event.target as HTMLInputElement).value)
}
</script>Parent:
<MyInput v-model="name" />Custom model name:
Child:
const props = defineProps<{
title: string
}>()
const emit = defineEmits<{
'update:title': [value: string]
}>()Parent:
<MyComponent v-model:title="pageTitle" />2.5 Slots & scoped slots #
Think of slots as template partials/layout regions where the parent injects markup.
Basic slot:
<!-- Card.vue -->
<template>
<div class="card">
<slot /> <!-- default slot -->
</div>
</template>Use:
<Card>
<h2>Title</h2>
<p>Body content</p>
</Card>Named slots:
<!-- Layout.vue -->
<template>
<header>
<slot name="header" />
</header>
<main>
<slot />
</main>
<footer>
<slot name="footer" />
</footer>
</template>Use:
<Layout>
<template #header>
<h1>My App</h1>
</template>
<template #default>
<RouterView />
</template>
<template #footer>
© 2025
</template>
</Layout>Scoped slots: child passes data up to parent-provided content.
Child:
<!-- UserList.vue -->
<template>
<ul>
<li v-for="user in users" :key="user.id">
<slot name="item" :user="user">
<!-- fallback if parent doesn’t provide slot -->
{{ user.name }}
</slot>
</li>
</ul>
</template>
<script setup lang="ts">
interface User {
id: number
name: string
}
const props = defineProps<{
users: User[]
}>()
</script>Parent:
<UserList :users="users">
<template #item="{ user }">
<strong>{{ user.name }}</strong>
</template>
</UserList>Conceptually similar to passing a callback into a renderer that receives context and returns markup.
2.6 Composables (like backend services/utilities) #
Composables = functions named useXxx that encapsulate state + logic and can be reused across components.
Example useCounter:
// useCounter.ts
import { ref, computed } from 'vue'
export function useCounter(initial = 0) {
const count = ref(initial)
const double = computed(() => count.value * 2)
function increment() {
count.value++
}
return {
count,
double,
increment
}
}Use in component:
<script setup lang="ts">
import { useCounter } from '@/composables/useCounter'
const { count, double, increment } = useCounter(5)
</script>
<template>
<button @click="increment">
{{ count }} (double: {{ double }})
</button>
</template>For API/domain logic:
// useUser.ts
import { ref } from 'vue'
import { getUser } from '@/api/user'
export function useUser(userId: number) {
const user = ref<User | null>(null)
const loading = ref(false)
const error = ref<Error | null>(null)
async function fetchUser() {
loading.value = true
error.value = null
try {
user.value = await getUser(userId)
} catch (err) {
error.value = err as Error
} finally {
loading.value = false
}
}
// optionally auto-fetch
fetchUser()
return { user, loading, error, fetchUser }
}This is closest to a service + query object from backend architectures.
3. MODERN PATTERNS & BEST PRACTICES #
3.1 Component composition patterns (avoiding prop drilling) #
Prop drilling = passing the same props through multiple layers just so a deeply nested child can use them.
Avoid with:
-
Composables + shared dependencies
- Deep components call the same composable instead of relying on props.
// useCurrentUser.ts import { computed } from 'vue' import { useUserStore } from '@/stores/userStore' export function useCurrentUser() { const userStore = useUserStore() return computed(() => userStore.currentUser) }
-
Provide / inject (see §3.6)
- Hierarchical context: theme, layout config, multistep wizard state.
-
Slots
- Pass children content instead of encoding every variation in props.
- Example:
<Table>that exposes#cell/#headerslots instead of endless prop permutations.
-
State colocation
- Keep state near the components that use it.
- Only lift state when multiple siblings or routes truly need to coordinate.
3.2 State management: local vs Pinia #
Local component state
- Use when:
- Only the component (and maybe its immediate children) need the data.
- Examples: modal open state, dropdown selection, unsaved form draft.
Pinia (global stores)
- Official Vue 3 store solution (successor to Vuex).
- Good when:
- Distant components share state.
- You need a single source of truth for domains:
- Auth user, permissions.
- Shopping cart.
- Feature flags / configuration.
- Integrates with DevTools, supports time-travel, persistence, plugins.
Example Pinia store:
// stores/userStore.ts
import { defineStore } from 'pinia'
interface User {
id: number
name: string
}
interface UserState {
currentUser: User | null
}
export const useUserStore = defineStore('user', {
state: (): UserState => ({
currentUser: null
}),
getters: {
isAuthenticated: (state) => !!state.currentUser
},
actions: {
setUser(user: User | null) {
this.currentUser = user
}
}
})Usage:
import { useUserStore } from '@/stores/userStore'
const userStore = useUserStore()
userStore.setUser({ id: 1, name: 'Alice' })
console.log(userStore.isAuthenticated) // truePinia vs TanStack Query
- Pinia: client-state (UI flags, in-progress edits, client-only models).
- TanStack Query: server-state (data whose source of truth is the backend and is cached client-side).
3.3 TypeScript integration: props, emits, generics #
Using <script setup lang="ts">:
Typed props
<script setup lang="ts">
interface User {
id: number
name: string
}
const props = defineProps<{
user: User
editable?: boolean
}>()
</script>Typed emits
const emit = defineEmits<{
(e: 'save', user: User): void
(e: 'cancel'): void
}>()
function onSave() {
emit('save', props.user)
}
function onCancel() {
emit('cancel')
}TypeScript enforces event names and payload shapes.
Generic components (simplified)
<!-- GenericList.vue -->
<script setup lang="ts">
type WithId = { id: string | number }
const props = defineProps<{
items: WithId[]
}>()
</script>
<template>
<ul>
<li v-for="item in items" :key="item.id">
<slot name="item" :item="item">
{{ item }}
</slot>
</li>
</ul>
</template>Type-safe Pinia
export interface CartItem {
id: number
title: string
qty: number
}
export const useCartStore = defineStore('cart', {
state: () => ({
items: [] as CartItem[]
}),
actions: {
addItem(item: CartItem) {
this.items.push(item)
}
}
})items and addItem are fully typed.
3.4 Performance: watch vs watchEffect, memoization, virtual lists #
computed vs watch vs watchEffect #
-
computed:- Derived, pure values.
- Cached; recomputes only when deps change.
-
watch(source, callback, options?):- For side effects based on specific reactive sources.
- Access to
newValue,oldValue.
import { ref, watch } from 'vue' const query = ref('') const results = ref<string[]>([]) watch( query, async (newQuery) => { if (newQuery.length < 3) { results.value = [] return } results.value = await searchApi(newQuery) } )
-
watchEffect(effect, options?):- Like a “reactive effect”: automatically tracks any reactive values used inside.
- Good for quick side effects spanning multiple sources.
import { watchEffect } from 'vue' import { useUserStore } from '@/stores/userStore' const userStore = useUserStore() watchEffect(() => { console.log('Current user id:', userStore.currentUser?.id) })
Guidelines:
- Prefer
computedfor derivations used in rendering or logic. - Use
watchwhen you need precise triggers or previous values. - Use
watchEffectfor small, implicit, multi-source side effects.
Memoization #
computedis already memoized.- For heavy but pure transformations of reactive data, wrap them in
computed. - For heavy computations over non-reactive data, use classic JS memoization (Maps, LRU caches, etc.).
Virtual lists #
Rendering thousands of nodes is slow. Use virtual scrolling:
- Libraries like
vue-virtual-scrolleror similar.
Pattern:
<VirtualList
:items="items"
:item-size="48"
v-slot="{ item }"
>
<YourItemComponent :item="item" />
</VirtualList>Only visible items are rendered to the DOM.
3.5 Error boundaries and logging #
Vue 3 options:
-
Component-level error boundary (
onErrorCaptured)<!-- ErrorBoundary.vue --> <template> <div v-if="error"> <p>Something went wrong.</p> <pre v-if="debug">{{ error.stack }}</pre> </div> <slot v-else /> </template> <script setup lang="ts"> import { ref, onErrorCaptured } from 'vue' const props = defineProps<{ debug?: boolean }>() const error = ref<Error | null>(null) onErrorCaptured((err) => { error.value = err as Error // return false to stop further propagation (optional) return false }) </script>
Usage:
<ErrorBoundary> <DangerousComponent /> </ErrorBoundary>
-
Global error handler
In
main.ts:import { createApp } from 'vue' import App from './App.vue' const app = createApp(App) app.config.errorHandler = (err, instance, info) => { logError({ message: (err as Error).message, stack: (err as Error).stack, component: instance?.$options.name, info }) } app.mount('#app')
-
Unhandled promise rejections
-
Catch in composables with
try/catch. -
Optionally:
window.addEventListener('unhandledrejection', (event) => { logError({ type: 'unhandledrejection', reason: event.reason }) })
-
Use Sentry, LogRocket, or your backend logging pipeline, ideally enriched with:
- current route,
- user id,
- environment.
3.6 Dependency injection (provide / inject) #
Analogous to scoped DI containers/context.
Define an injection key
import type { InjectionKey, Ref } from 'vue'
export const ThemeKey: InjectionKey<Ref<'light' | 'dark'>> = Symbol('theme')Provide in an ancestor component
import { ref, provide } from 'vue'
import { ThemeKey } from '@/di/keys'
const theme = ref<'light' | 'dark'>('light')
provide(ThemeKey, theme)Inject in any descendant
import { inject } from 'vue'
import { ThemeKey } from '@/di/keys'
const theme = inject(ThemeKey)
if (!theme) {
throw new Error('Theme not provided')
}
theme.value = 'dark'Use provide/inject for:
- Cross-cutting concerns: theme, i18n, layout config.
- Complex component trees (stepper, wizard) to share internal state.
Don’t use it as a replacement for global app state (Pinia is better for that).
4. ECOSYSTEM INTEGRATION #
4.1 Vue Router: navigation guards, lazy loading #
Basic setup with lazy-loaded routes
// router.ts
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
const routes: RouteRecordRaw[] = [
{
path: '/',
name: 'home',
component: () => import('@/views/HomeView.vue') // lazy
},
{
path: '/users/:id',
name: 'user',
component: () => import('@/views/UserView.vue'),
meta: { requiresAuth: true }
}
]
export const router = createRouter({
history: createWebHistory(),
routes
})In main.ts:
import { createApp } from 'vue'
import App from './App.vue'
import { router } from './router'
createApp(App)
.use(router)
.mount('#app')In-component usage
import { useRouter, useRoute } from 'vue-router'
const router = useRouter()
const route = useRoute()
router.push({ name: 'user', params: { id: 123 } })
console.log(route.params.id)Global navigation guard (auth)
// router.ts
import { useUserStore } from '@/stores/userStore'
router.beforeEach((to, from, next) => {
const userStore = useUserStore()
if (to.meta.requiresAuth && !userStore.isAuthenticated) {
return next({ name: 'login', query: { redirect: to.fullPath } })
}
next()
})Per-route guard
const routes: RouteRecordRaw[] = [
{
path: '/admin',
name: 'admin',
component: () => import('@/views/AdminView.vue'),
beforeEnter: (to, from, next) => {
const userStore = useUserStore()
if (!userStore.isAdmin) return next({ name: 'home' })
next()
}
}
]In-component guards (for unsaved changes)
import { ref } from 'vue'
import { onBeforeRouteLeave } from 'vue-router'
const hasUnsavedChanges = ref(false)
onBeforeRouteLeave((to, from, next) => {
if (hasUnsavedChanges.value) {
const ignore = !window.confirm('Discard unsaved changes?')
if (ignore) return next(false)
}
next()
})4.2 API integration: TanStack Query vs axios #
axios: low-level HTTP client
// api/http.ts
import axios from 'axios'
export const http = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
withCredentials: true
})
http.interceptors.request.use((config) => {
const authStore = useAuthStore()
if (authStore.token) {
config.headers = config.headers ?? {}
config.headers.Authorization = `Bearer ${authStore.token}`
}
return config
})
http.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
useAuthStore().logout()
}
return Promise.reject(error)
}
)TanStack Query (Vue Query): server-state manager
Handles:
- Caching.
- Deduped requests.
- Background refetch.
- Stale vs fresh data.
Setup:
// main.ts
import { createApp } from 'vue'
import App from './App.vue'
import { VueQueryPlugin, type VueQueryPluginOptions } from '@tanstack/vue-query'
const vueQueryOptions: VueQueryPluginOptions = {
queryClientConfig: {
defaultOptions: {
queries: {
staleTime: 60_000,
retry: 1
}
}
}
}
createApp(App)
.use(VueQueryPlugin, vueQueryOptions)
.mount('#app')Define a query hook:
// queries/useUserQuery.ts
import { useQuery } from '@tanstack/vue-query'
import { http } from '@/api/http'
export interface User {
id: number
name: string
}
function fetchUser(id: number): Promise<User> {
return http.get(`/users/${id}`).then((res) => res.data as User)
}
export function useUserQuery(id: number) {
return useQuery({
queryKey: ['user', id],
queryFn: () => fetchUser(id)
})
}Use in a component:
<script setup lang="ts">
import { useRoute } from 'vue-router'
import { useUserQuery } from '@/queries/useUserQuery'
const route = useRoute()
const userId = Number(route.params.id)
const { data: user, isLoading, error } = useUserQuery(userId)
</script>
<template>
<div v-if="isLoading">Loading...</div>
<div v-else-if="error">Failed to load user.</div>
<div v-else>
{{ user.name }}
</div>
</template>When to use what
- Simple apps / limited endpoints:
- axios (or
fetch) inside composables is fine.
- axios (or
- Larger apps with lots of shared server data:
- axios (or fetch) + TanStack Query for server-state.
- Pinia for client-state.
4.3 Form handling: VeeValidate / FormKit with backend validation sync #
VeeValidate #
- Form handling + validation library with schema support (Yup/Zod, etc.).
Example:
<script setup lang="ts">
import { Form, Field, ErrorMessage } from 'vee-validate'
import * as yup from 'yup'
const schema = yup.object({
email: yup.string().required().email(),
password: yup.string().required().min(8)
})
async function onSubmit(values: { email: string; password: string }) {
try {
await login(values)
} catch (e: any) {
// Option 1: throw object with field-level errors:
// throw { email: 'Email already taken' }
// Option 2: use setFieldError (see VeeValidate docs)
}
}
</script>
<template>
<Form :validation-schema="schema" @submit="onSubmit">
<div>
<label>Email</label>
<Field name="email" type="email" />
<ErrorMessage name="email" />
</div>
<div>
<label>Password</label>
<Field name="password" type="password" />
<ErrorMessage name="password" />
</div>
<button type="submit">Login</button>
</Form>
</template>Sync with backend validation
Define error response like:
{
"errors": {
"email": ["Email is already taken"],
"password": ["Password too weak"]
}
}Then, in onSubmit, catch the error and map these to field errors using VeeValidate APIs.
FormKit #
- Higher-level, schema-driven form system.
- Good for complex or dynamic forms.
Simple example:
<FormKit
type="form"
v-model="formData"
:actions="false"
@submit="onSubmit"
>
<FormKit
name="email"
type="email"
label="Email"
validation="required|email"
/>
<FormKit
name="password"
type="password"
label="Password"
validation="required|length:8"
/>
<button type="submit">Submit</button>
</FormKit>Concept is similar: map backend field errors into FormKit’s error system.
4.4 Testing: Vitest + Testing Library #
- Vitest: Vite-native test runner (Jest-like API).
- Vue Testing Library: Testing Library for Vue (focus on user-centric tests).
Component:
<!-- Counter.vue -->
<template>
<button @click="increment">{{ count }}</button>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const count = ref(0)
function increment() {
count.value++
}
</script>Test:
// Counter.spec.ts
import { render, fireEvent, screen } from '@testing-library/vue'
import Counter from './Counter.vue'
import { describe, it, expect } from 'vitest'
describe('Counter', () => {
it('increments on click', async () => {
render(Counter)
const button = screen.getByRole('button')
expect(button.textContent).toBe('0')
await fireEvent.click(button)
expect(button.textContent).toBe('1')
})
})Testing a composable:
// useCounter.spec.ts
import { describe, it, expect } from 'vitest'
import { useCounter } from '@/composables/useCounter'
describe('useCounter', () => {
it('increments', () => {
const { count, increment } = useCounter(0)
expect(count.value).toBe(0)
increment()
expect(count.value).toBe(1)
})
})Patterns:
- Test components by behavior and rendered output, not internal refs.
- Wrap tests with providers (router, Pinia, Vue Query) as needed.
4.5 DevTools and debugging techniques #
Vue DevTools
- Inspect component tree.
- View props, state, computed values.
- Time-travel debugging for Pinia.
- Inspect router and Vuex/Pinia/Query plugins.
Debugging tips
-
Use browser DevTools:
- Place
debuggerinsidesetupor composables. - Vite’s source maps let you debug
.vueSFCs and TS.
- Place
-
Enable performance markers (dev only):
import { createApp } from 'vue' import App from './App.vue' const app = createApp(App) app.config.performance = true app.mount('#app')
-
Common reactivity gotchas:
- Changing a non-reactive object won’t trigger updates:
- Ensure you used
ref/reactiveto create state.
- Ensure you used
- Replacing a whole reactive object:
- With
reactive, prefer mutating properties instead of reassigning the variable. - Or use
reffor the object if you need to replace it wholesale.
- With
- Changing a non-reactive object won’t trigger updates:
defineExpose for introspection
<!-- Child.vue -->
<script setup lang="ts">
import { ref, defineExpose } from 'vue'
const count = ref(0)
defineExpose({ count })
</script>Parent (in dev console):
- Get a ref to child (
ref="childRef"). - Inspect
childRef.value.count.
Linting & tooling
- ESLint + Prettier + TypeScript:
eslint-plugin-vue@vue/eslint-config-typescript
- Vite +
@vitejs/plugin-vue:- Fast HMR and excellent DX for Vue 3.