Skip to main content
Advertisement

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 onMounted are supported
  • Return only the state and functions needed externally
  • Outside components, use app.runWithContext() or Pinia instead

Key <script setup> APIs

APIPurpose
definePropsDeclare props
defineEmitsDeclare emitted events
defineExposeExpose members to the parent
defineModelSimplified v-model (3.4+)
useTemplateRefType-safe template ref (3.5+)
withDefaultsSet prop defaults (TS)

Key Vitest Functions

FunctionPurpose
describeTest group
it / testIndividual test case
expectAssertion
beforeEachRuns 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
Advertisement