14.7 Vue 3 Pro Tips for Production
Using Vue 3 at a professional level requires more than knowing the basic syntax. This chapter covers four key topics — Composable patterns, advanced <script setup>, TypeScript integration, and Vitest testing — to help you build production-quality Vue applications.
1. Composable Patterns
A Composable is a function that encapsulates state and logic into a reusable unit based on Vue 3's Composition API. This is the same concept as React's Custom Hooks.
Basic Structure
// composables/useCounter.js
import { ref, computed } from 'vue'
export function useCounter(initialValue = 0) {
const count = ref(initialValue)
const doubled = computed(() => count.value * 2)
function increment() { count.value++ }
function decrement() { count.value-- }
function reset() { count.value = initialValue }
return { count, doubled, increment, decrement, reset }
}
<!-- Using it in a component -->
<script setup>
import { useCounter } from '@/composables/useCounter'
const { count, doubled, increment, decrement, reset } = useCounter(10)
</script>
<template>
<div>
<p>{{ count }} (doubled: {{ doubled }})</p>
<button @click="increment">+</button>
<button @click="decrement">-</button>
<button @click="reset">Reset</button>
</div>
</template>
Real-World Example: Async Data Fetching
// composables/useFetch.js
import { ref, watchEffect, toValue } from 'vue'
export function useFetch(url) {
const data = ref(null)
const error = ref(null)
const isLoading = ref(false)
async function fetchData() {
// toValue(): handles ref, getter, or plain value (Vue 3.3+)
const resolvedUrl = toValue(url)
if (!resolvedUrl) return
isLoading.value = true
error.value = null
try {
const response = await fetch(resolvedUrl)
if (!response.ok) throw new Error(`HTTP ${response.status}`)
data.value = await response.json()
} catch (err) {
error.value = err.message
} finally {
isLoading.value = false
}
}
// If url is reactive, automatically re-fetches when it changes
watchEffect(() => fetchData())
return { data, error, isLoading, refetch: fetchData }
}
<script setup>
import { ref } from 'vue'
import { useFetch } from '@/composables/useFetch'
const userId = ref(1)
// Automatically fetches new data whenever userId changes
const { data: user, error, isLoading } = useFetch(
() => `https://jsonplaceholder.typicode.com/users/${userId.value}`
)
</script>
<template>
<div>
<button @click="userId++">Next User</button>
<p v-if="isLoading">Loading...</p>
<p v-else-if="error">Error: {{ error }}</p>
<pre v-else>{{ user }}</pre>
</div>
</template>
Real-World Example: Form Validation Composable
// composables/useForm.js
import { reactive, computed } from 'vue'
export function useForm(initialValues, validationRules) {
const values = reactive({ ...initialValues })
const touched = reactive({})
const errors = reactive({})
function validate(field) {
const rule = validationRules[field]
if (!rule) return true
const value = values[field]
const error = rule(value, values)
if (error) {
errors[field] = error
return false
} else {
delete errors[field]
return true
}
}
function handleBlur(field) {
touched[field] = true
validate(field)
}
function validateAll() {
let isValid = true
for (const field in validationRules) {
touched[field] = true
if (!validate(field)) isValid = false
}
return isValid
}
const isValid = computed(() => Object.keys(errors).length === 0)
const isDirty = computed(() =>
Object.keys(initialValues).some((key) => values[key] !== initialValues[key])
)
function reset() {
Object.assign(values, initialValues)
Object.keys(touched).forEach((k) => delete touched[k])
Object.keys(errors).forEach((k) => delete errors[k])
}
return { values, touched, errors, isValid, isDirty, handleBlur, validateAll, reset }
}
<!-- RegisterForm.vue -->
<script setup>
import { useForm } from '@/composables/useForm'
const { values, touched, errors, isValid, handleBlur, validateAll, reset } = useForm(
{ name: '', email: '', password: '' },
{
name: (v) => !v ? 'Name is required' : v.length < 2 ? 'Must be at least 2 characters' : null,
email: (v) => !v ? 'Email is required' : !/\S+@\S+\.\S+/.test(v) ? 'Invalid email format' : null,
password: (v) => !v ? 'Password is required' : v.length < 8 ? 'Must be at least 8 characters' : null,
}
)
function handleSubmit() {
if (validateAll()) {
console.log('Submitting:', values)
}
}
</script>
<template>
<form @submit.prevent="handleSubmit">
<div>
<input v-model="values.name" placeholder="Name" @blur="handleBlur('name')" />
<span v-if="touched.name && errors.name" class="error">{{ errors.name }}</span>
</div>
<div>
<input v-model="values.email" placeholder="Email" @blur="handleBlur('email')" />
<span v-if="touched.email && errors.email" class="error">{{ errors.email }}</span>
</div>
<div>
<input v-model="values.password" type="password" placeholder="Password" @blur="handleBlur('password')" />
<span v-if="touched.password && errors.password" class="error">{{ errors.password }}</span>
</div>
<button type="submit" :disabled="!isValid">Sign Up</button>
<button type="button" @click="reset">Reset</button>
</form>
</template>
2. Advanced <script setup>
<script setup> transforms the setup() function into compile-time syntactic sugar, enabling more concise code.
defineProps and defineEmits
<!-- ParentComponent.vue -->
<script setup>
import { ref } from 'vue'
import ChildComponent from './ChildComponent.vue'
const parentMessage = ref('Parent message')
const items = ref(['Apple', 'Banana', 'Cherry'])
function handleUpdate(newMessage) {
parentMessage.value = newMessage
}
</script>
<template>
<ChildComponent
:message="parentMessage"
:items="items"
@update:message="handleUpdate"
@item-selected="console.log('Selected:', $event)"
/>
</template>
<!-- ChildComponent.vue -->
<script setup>
// Props definition (type + default + required)
const props = defineProps({
message: {
type: String,
required: true,
},
items: {
type: Array,
default: () => [],
},
count: {
type: Number,
default: 0,
validator: (v) => v >= 0,
},
})
// Emits definition (event name + validation)
const emit = defineEmits({
'update:message': (value) => typeof value === 'string',
'item-selected': (item) => item !== undefined,
})
function updateMessage() {
emit('update:message', 'New message')
}
function selectItem(item) {
emit('item-selected', item)
}
</script>
<template>
<div>
<p>{{ message }}</p>
<ul>
<li
v-for="item in items"
:key="item"
@click="selectItem(item)"
style="cursor: pointer"
>
{{ item }}
</li>
</ul>
<button @click="updateMessage">Update Message</button>
</div>
</template>
defineExpose — Calling Child Methods from Parent
<!-- InputWithValidation.vue -->
<script setup>
import { ref } from 'vue'
const inputRef = ref(null)
const value = ref('')
const isValid = ref(true)
function focus() {
inputRef.value?.focus()
}
function validate() {
isValid.value = value.value.length >= 3
return isValid.value
}
function clear() {
value.value = ''
isValid.value = true
}
// Explicitly expose members for the parent component to access
defineExpose({ focus, validate, clear })
</script>
<template>
<input
ref="inputRef"
v-model="value"
:class="{ invalid: !isValid }"
placeholder="Enter at least 3 characters"
/>
</template>
<!-- ParentForm.vue -->
<script setup>
import { ref } from 'vue'
import InputWithValidation from './InputWithValidation.vue'
const inputRef = ref(null)
function submitForm() {
if (inputRef.value?.validate()) {
console.log('Form submitted')
} else {
inputRef.value?.focus()
}
}
</script>
<template>
<form @submit.prevent="submitForm">
<InputWithValidation ref="inputRef" />
<button type="submit">Submit</button>
</form>
</template>
useTemplateRef — New in Vue 3.5
<script setup>
import { useTemplateRef, onMounted } from 'vue'
// Vue 3.5+: type-safe template ref
const inputEl = useTemplateRef('my-input')
onMounted(() => {
inputEl.value?.focus()
})
</script>
<template>
<!-- The ref name must match the useTemplateRef argument -->
<input ref="my-input" placeholder="Auto-focused" />
</template>
defineModel — Simplified v-model (Vue 3.4+)
<!-- BeforeVue34.vue (old way) -->
<script setup>
const props = defineProps({ modelValue: String })
const emit = defineEmits(['update:modelValue'])
function onInput(e) {
emit('update:modelValue', e.target.value)
}
</script>
<!-- AfterVue34.vue (new way — Vue 3.4+) -->
<script setup>
const model = defineModel() // automatically handles props + emit
</script>
<template>
<input :value="model" @input="model = $event.target.value" />
</template>
<!-- Supporting multiple v-model bindings -->
<script setup>
const firstName = defineModel('firstName')
const lastName = defineModel('lastName')
</script>
<template>
<input v-model="firstName" placeholder="First name" />
<input v-model="lastName" placeholder="Last name" />
</template>
3. TypeScript Integration
Vue 3 was written in TypeScript from the ground up, providing excellent type support.
Basic Setup
npm create vue@latest my-vue-ts-app
# TypeScript support? → Yes
// tsconfig.json (key settings)
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"jsx": "preserve",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"paths": {
"@/*": ["./src/*"]
}
}
}
Writing Components with TypeScript
<!-- UserProfile.vue -->
<script setup lang="ts">
import { ref, computed } from 'vue'
// Type definitions
interface User {
id: number
name: string
email: string
avatar?: string
role: 'admin' | 'editor' | 'viewer'
}
interface Props {
user: User
showEmail?: boolean
}
// withDefaults: set default values
const props = withDefaults(defineProps<Props>(), {
showEmail: false,
})
const emit = defineEmits<{
'edit': [user: User]
'delete': [userId: number]
'role-change': [userId: number, role: User['role']]
}>()
const isExpanded = ref(false)
const displayName = computed(() =>
props.user.name.split(' ').map((n) => n[0].toUpperCase() + n.slice(1)).join(' ')
)
function handleEdit() {
emit('edit', props.user)
}
function handleRoleChange(newRole: User['role']) {
emit('role-change', props.user.id, newRole)
}
</script>
<template>
<div class="user-profile">
<img :src="user.avatar ?? '/default-avatar.png'" :alt="user.name" />
<h3>{{ displayName }}</h3>
<p v-if="showEmail">{{ user.email }}</p>
<span class="role-badge">{{ user.role }}</span>
<div v-if="isExpanded" class="actions">
<button @click="handleEdit">Edit</button>
<select @change="handleRoleChange(($event.target as HTMLSelectElement).value as User['role'])">
<option value="admin">Admin</option>
<option value="editor">Editor</option>
<option value="viewer">Viewer</option>
</select>
</div>
<button @click="isExpanded = !isExpanded">
{{ isExpanded ? 'Collapse' : 'Expand' }}
</button>
</div>
</template>
TypeScript Composable
// composables/useLocalStorage.ts
import { ref, watch } from 'vue'
export function useLocalStorage<T>(key: string, defaultValue: T) {
const storedValue = localStorage.getItem(key)
const initial: T = storedValue ? (JSON.parse(storedValue) as T) : defaultValue
const value = ref<T>(initial)
watch(
value,
(newValue) => {
localStorage.setItem(key, JSON.stringify(newValue))
},
{ deep: true }
)
function remove() {
localStorage.removeItem(key)
value.value = defaultValue
}
return { value, remove }
}
<script setup lang="ts">
import { useLocalStorage } from '@/composables/useLocalStorage'
interface Settings {
theme: 'light' | 'dark'
language: string
notifications: boolean
}
// Types are automatically inferred
const { value: settings } = useLocalStorage<Settings>('app-settings', {
theme: 'light',
language: 'en',
notifications: true,
})
</script>
<template>
<div>
<button @click="settings.theme = settings.theme === 'light' ? 'dark' : 'light'">
Theme: {{ settings.theme }}
</button>
</div>
</template>
4. Testing with Vitest
Vitest is a fast, lightweight test framework for Vite-based Vue projects. It provides a Jest-compatible API.
Installation
npm install -D vitest @vue/test-utils jsdom @vitejs/plugin-vue
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
test: {
environment: 'jsdom',
globals: true,
},
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
})
Testing a Composable
// composables/__tests__/useCounter.test.ts
import { describe, it, expect, beforeEach } from 'vitest'
import { useCounter } from '@/composables/useCounter'
describe('useCounter', () => {
it('starts with the initial value', () => {
const { count } = useCounter(5)
expect(count.value).toBe(5)
})
it('increment increases count by 1', () => {
const { count, increment } = useCounter(0)
increment()
expect(count.value).toBe(1)
})
it('decrement decreases count by 1', () => {
const { count, decrement } = useCounter(5)
decrement()
expect(count.value).toBe(4)
})
it('reset returns to the initial value', () => {
const { count, increment, reset } = useCounter(3)
increment()
increment()
expect(count.value).toBe(5)
reset()
expect(count.value).toBe(3)
})
it('doubled returns twice the count', () => {
const { count, doubled, increment } = useCounter(2)
expect(doubled.value).toBe(4)
increment()
expect(doubled.value).toBe(6)
})
})
Testing a Component
// components/__tests__/ShoppingCart.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import ShoppingCart from '@/components/ShoppingCart.vue'
import { useCartStore } from '@/stores/cart'
describe('ShoppingCart', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('renders the empty cart message', () => {
const wrapper = mount(ShoppingCart)
expect(wrapper.text()).toContain('Your cart is empty')
})
it('displays the item list when products are added', async () => {
const cart = useCartStore()
cart.addItem({ id: 1, name: 'Apple', price: 1.99, image: '/apple.jpg' })
const wrapper = mount(ShoppingCart)
expect(wrapper.text()).toContain('Apple')
})
it('removes an item when the delete button is clicked', async () => {
const cart = useCartStore()
cart.addItem({ id: 1, name: 'Apple', price: 1.99, image: '/apple.jpg' })
const wrapper = mount(ShoppingCart)
await wrapper.find('.remove-btn').trigger('click')
expect(cart.items).toHaveLength(0)
})
it('calculates totalItems correctly', () => {
const cart = useCartStore()
cart.addItem({ id: 1, name: 'Apple', price: 1.99, image: '/apple.jpg' })
cart.addItem({ id: 1, name: 'Apple', price: 1.99, image: '/apple.jpg' }) // increases quantity
cart.addItem({ id: 2, name: 'Banana', price: 0.99, image: '/banana.jpg' })
expect(cart.totalItems).toBe(3)
})
})
Testing an Async Composable
// composables/__tests__/useFetch.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { nextTick } from 'vue'
import { useFetch } from '@/composables/useFetch'
describe('useFetch', () => {
beforeEach(() => {
// Replace fetch with a mock
global.fetch = vi.fn()
})
afterEach(() => {
vi.restoreAllMocks()
})
it('successfully fetches data', async () => {
const mockData = { id: 1, name: 'Test User' }
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => mockData,
} as Response)
const { data, isLoading, error } = useFetch('https://api.example.com/user/1')
// Initial state: loading
expect(isLoading.value).toBe(true)
await nextTick()
await nextTick()
expect(isLoading.value).toBe(false)
expect(data.value).toEqual(mockData)
expect(error.value).toBeNull()
})
it('sets error state when a failure occurs', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: false,
status: 404,
} as Response)
const { data, error, isLoading } = useFetch('https://api.example.com/not-found')
await nextTick()
await nextTick()
expect(isLoading.value).toBe(false)
expect(error.value).toBe('HTTP 404')
expect(data.value).toBeNull()
})
})
Testing a Pinia Store
// stores/__tests__/cart.test.ts
import { describe, it, expect, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useCartStore } from '@/stores/cart'
describe('useCartStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('starts with an empty state', () => {
const cart = useCartStore()
expect(cart.items).toHaveLength(0)
expect(cart.isEmpty).toBe(true)
expect(cart.totalItems).toBe(0)
})
it('increases quantity when the same product is added', () => {
const cart = useCartStore()
const product = { id: 1, name: 'Apple', price: 1.99, image: '/apple.jpg' }
cart.addItem(product)
cart.addItem(product)
expect(cart.items).toHaveLength(1)
expect(cart.items[0].quantity).toBe(2)
})
it('removes the item when quantity is updated to 0', () => {
const cart = useCartStore()
cart.addItem({ id: 1, name: 'Apple', price: 1.99, image: '/apple.jpg' })
cart.updateQuantity(1, 0)
expect(cart.items).toHaveLength(0)
})
it('calculates totalPrice correctly', () => {
const cart = useCartStore()
cart.addItem({ id: 1, name: 'Apple', price: 1.0, image: '/apple.jpg' })
cart.addItem({ id: 2, name: 'Banana', price: 0.5, image: '/banana.jpg' })
cart.updateQuantity(1, 3) // 3 apples: $3.00
expect(cart.totalPrice).toBe(3.5) // 3.00 + 0.50
})
})
Integrated Real-World Example: Complete Todo App
Store
// stores/todo.ts
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
export interface Todo {
id: number
text: string
completed: boolean
createdAt: Date
}
export type Filter = 'all' | 'active' | 'completed'
export const useTodoStore = defineStore('todo', () => {
const todos = ref<Todo[]>([])
const filter = ref<Filter>('all')
const filteredTodos = computed(() => {
switch (filter.value) {
case 'active': return todos.value.filter((t) => !t.completed)
case 'completed': return todos.value.filter((t) => t.completed)
default: return todos.value
}
})
const stats = computed(() => ({
total: todos.value.length,
active: todos.value.filter((t) => !t.completed).length,
completed: todos.value.filter((t) => t.completed).length,
}))
function addTodo(text: string) {
if (!text.trim()) return
todos.value.push({
id: Date.now(),
text: text.trim(),
completed: false,
createdAt: new Date(),
})
}
function toggleTodo(id: number) {
const todo = todos.value.find((t) => t.id === id)
if (todo) todo.completed = !todo.completed
}
function removeTodo(id: number) {
todos.value = todos.value.filter((t) => t.id !== id)
}
function clearCompleted() {
todos.value = todos.value.filter((t) => !t.completed)
}
return { todos, filter, filteredTodos, stats, addTodo, toggleTodo, removeTodo, clearCompleted }
})
Component
<!-- TodoApp.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import { storeToRefs } from 'pinia'
import { useTodoStore } from '@/stores/todo'
import type { Filter } from '@/stores/todo'
const store = useTodoStore()
const { filteredTodos, filter, stats } = storeToRefs(store)
const newTodoText = ref('')
function handleAdd() {
store.addTodo(newTodoText.value)
newTodoText.value = ''
}
</script>
<template>
<div class="todo-app">
<h1>Todo List</h1>
<form @submit.prevent="handleAdd" class="add-form">
<input
v-model="newTodoText"
placeholder="Enter a new todo..."
class="todo-input"
/>
<button type="submit">Add</button>
</form>
<div class="filters">
<button
v-for="f in (['all', 'active', 'completed'] as Filter[])"
:key="f"
:class="{ active: filter === f }"
@click="store.filter = f"
>
{{ f.charAt(0).toUpperCase() + f.slice(1) }}
</button>
</div>
<ul class="todo-list">
<li
v-for="todo in filteredTodos"
:key="todo.id"
:class="{ completed: todo.completed }"
class="todo-item"
>
<input
type="checkbox"
:checked="todo.completed"
@change="store.toggleTodo(todo.id)"
/>
<span>{{ todo.text }}</span>
<button @click="store.removeTodo(todo.id)">Delete</button>
</li>
</ul>
<div class="stats">
<span>Total: {{ stats.total }} / Active: {{ stats.active }} / Done: {{ stats.completed }}</span>
<button v-if="stats.completed > 0" @click="store.clearCompleted">
Clear Completed
</button>
</div>
</div>
</template>
Tests
// stores/__tests__/todo.test.ts
import { describe, it, expect, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useTodoStore } from '@/stores/todo'
describe('useTodoStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('can add a todo', () => {
const store = useTodoStore()
store.addTodo('Study Vue')
expect(store.todos).toHaveLength(1)
expect(store.todos[0].text).toBe('Study Vue')
expect(store.todos[0].completed).toBe(false)
})
it('does not add an empty todo', () => {
const store = useTodoStore()
store.addTodo(' ')
expect(store.todos).toHaveLength(0)
})
it('toggle works correctly', () => {
const store = useTodoStore()
store.addTodo('Test')
const id = store.todos[0].id
store.toggleTodo(id)
expect(store.todos[0].completed).toBe(true)
store.toggleTodo(id)
expect(store.todos[0].completed).toBe(false)
})
it('filter works correctly', () => {
const store = useTodoStore()
store.addTodo('Todo 1')
store.addTodo('Todo 2')
store.toggleTodo(store.todos[0].id)
store.filter = 'active'
expect(store.filteredTodos).toHaveLength(1)
store.filter = 'completed'
expect(store.filteredTodos).toHaveLength(1)
store.filter = 'all'
expect(store.filteredTodos).toHaveLength(2)
})
})
Quick Reference Cheat Sheet
Composable Checklist
- Function name starts with
use(useCounter,useFetch) - Uses Vue reactivity APIs inside (
ref,computed,watch) - Lifecycle hooks like
onMountedare supported - Return only the state and functions needed externally
- Outside components, use
app.runWithContext()or Pinia instead
Key <script setup> APIs
| API | Purpose |
|---|---|
defineProps | Declare props |
defineEmits | Declare emitted events |
defineExpose | Expose members to the parent |
defineModel | Simplified v-model (3.4+) |
useTemplateRef | Type-safe template ref (3.5+) |
withDefaults | Set prop defaults (TS) |
Key Vitest Functions
| Function | Purpose |
|---|---|
describe | Test group |
it / test | Individual test case |
expect | Assertion |
beforeEach | Runs before each test |
vi.fn() | Create a mock function |
vi.mocked() | Cast to mock type |
vi.spyOn() | Spy on an existing function |
nextTick() | Wait for DOM updates |