| name | vuejs |
| description | Expert knowledge for working with VueJS, the preferred frontend framework we use along with Vite as a bundler |
VueJS
VueJS is the preferred frontend framework we use along with Vite as a bundler.
Core Principles
- Applications should stay current with Vue 3
- Always use Composition API (never Options API)
- Default to
<script setup>syntax over plain<script>blocks - Strong TypeScript integration is expected
- Use VueUse for common composable patterns
- Use Vite as the standard bundler
- Styling / CSS
- Use the Unocss framework over Tailwind CSS
- Use both flex and grid layouts but prefer grid in cases where both are appropriate.
Composition API Overview
The Composition API organizes component logic by feature rather than by option type. It enables better code reuse through composables and provides superior TypeScript support.
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
// Reactive state
const count = ref(0)
// Computed property
const doubled = computed(() => count.value * 2)
// Methods
function increment() {
count.value++
}
// Lifecycle
onMounted(() => {
console.log('Component mounted')
})
</script>
<template>
<button @click="increment">Count: {{ count }} (doubled: {{ doubled }})</button>
</template>
Script Setup
<script setup> is syntactic sugar that provides:
- Less boilerplate code
- Better runtime performance (template compiles in same scope)
- Superior TypeScript type inference
- Automatic exposure of top-level bindings to template
Compiler Macros
These macros are globally available in <script setup> without imports:
defineProps
// Runtime declaration
const props = defineProps({
title: { type: String, required: true },
count: { type: Number, default: 0 }
})
// Type-based declaration (preferred)
const props = defineProps<{
title: string
count?: number
}>()
// With defaults (type-based)
const props = withDefaults(defineProps<{
title: string
count?: number
}>(), {
count: 0
})
// Destructuring with defaults (3.5+)
const { title, count = 0 } = defineProps<{
title: string
count?: number
}>()
defineEmits
// Runtime declaration
const emit = defineEmits(['update', 'delete'])
// Type-based declaration (preferred)
const emit = defineEmits<{
update: [id: number, value: string]
delete: [id: number]
}>()
// Usage
emit('update', 1, 'new value')
defineModel
Creates two-way binding props for v-model:
// Default model (v-model)
const modelValue = defineModel<string>()
modelValue.value = 'hello' // emits 'update:modelValue'
// Named model (v-model:count)
const count = defineModel<number>('count', { default: 0 })
// With options
const title = defineModel<string>('title', {
required: true,
default: 'Untitled'
})
defineExpose
Explicitly expose properties to parent components via template refs:
const internalState = ref(0)
const publicMethod = () => { /* ... */ }
// Only publicMethod is accessible via ref
defineExpose({
publicMethod
})
defineOptions
Declare component options (Vue 3.3+):
defineOptions({
inheritAttrs: false,
name: 'CustomComponent'
})
defineSlots
Type slot props (Vue 3.3+):
const slots = defineSlots<{
default(props: { item: Item }): any
header(): any
}>()
Accessing Slots and Attrs
import { useSlots, useAttrs } from 'vue'
const slots = useSlots()
const attrs = useAttrs()
Top-Level Await
Async operations are supported (requires <Suspense> wrapper):
<script setup>
const data = await fetch('/api/data').then(r => r.json())
</script>
Reactivity System
ref()
Wraps values in a reactive container. Preferred for most cases.
import { ref } from 'vue'
const count = ref(0)
const user = ref<User | null>(null)
// Access/modify via .value
count.value++
user.value = { name: 'Ken' }
// Auto-unwraps in templates
// <template>{{ count }}</template> - no .value needed
Advantages:
- Works with primitives and objects
- Can be passed to functions while retaining reactivity
- Explicit
.valuemakes reactivity visible
reactive()
Makes objects deeply reactive without wrapping:
import { reactive } from 'vue'
const state = reactive({
count: 0,
user: { name: 'Ken' }
})
// Direct property access
state.count++
state.user.name = 'Kenneth'
Limitations (prefer ref() instead):
- Only works with objects, not primitives
- Cannot replace entire object:
state = { ... }breaks reactivity - Destructuring loses reactivity:
const { count } = stateis not reactive
computed()
Creates cached derived values:
import { ref, computed } from 'vue'
const firstName = ref('Ken')
const lastName = ref('Snyder')
// Read-only computed
const fullName = computed(() => `${firstName.value} ${lastName.value}`)
// Writable computed (rare)
const fullNameWritable = computed({
get: () => `${firstName.value} ${lastName.value}`,
set: (value: string) => {
const [first, last] = value.split(' ')
firstName.value = first
lastName.value = last
}
})
Best practices:
- Keep getters pure (no side effects, async, or DOM mutations)
- Computed values are cached until dependencies change
- Use methods when caching isn't beneficial
watch()
Watches specific reactive sources:
import { ref, watch } from 'vue'
const count = ref(0)
const user = ref<User | null>(null)
// Watch single ref
watch(count, (newValue, oldValue) => {
console.log(`Count changed: ${oldValue} -> ${newValue}`)
})
// Watch multiple sources
watch([count, user], ([newCount, newUser], [oldCount, oldUser]) => {
// ...
})
// Watch getter function
watch(
() => user.value?.name,
(newName) => console.log(`Name: ${newName}`)
)
// Options
watch(count, callback, {
immediate: true, // Run immediately on creation
deep: true, // Deep watch nested objects (use sparingly)
once: true, // Run only once (3.4+)
flush: 'post' // Run after DOM updates
})
// Stop watching
const stop = watch(count, callback)
stop() // Stop the watcher
watchEffect()
Automatically tracks all reactive dependencies accessed during execution:
import { ref, watchEffect } from 'vue'
const count = ref(0)
const name = ref('Ken')
// Automatically tracks count and name
watchEffect(() => {
console.log(`Count: ${count.value}, Name: ${name.value}`)
})
// With cleanup
watchEffect((onCleanup) => {
const controller = new AbortController()
fetch('/api/data', { signal: controller.signal })
onCleanup(() => controller.abort())
})
When to use which:
watch(): When you need old/new values, or want explicit control over dependencieswatchEffect(): When callback uses the same values it needs to watch
nextTick()
Wait for DOM updates after state changes:
import { ref, nextTick } from 'vue'
const count = ref(0)
async function increment() {
count.value++
await nextTick()
// DOM is now updated
}
Lifecycle Hooks
All hooks must be called synchronously during setup():
import {
onBeforeMount,
onMounted,
onBeforeUpdate,
onUpdated,
onBeforeUnmount,
onUnmounted,
onErrorCaptured,
onActivated,
onDeactivated
} from 'vue'
// Most commonly used
onMounted(() => {
// Component is mounted, DOM is available
console.log('Mounted')
})
onUnmounted(() => {
// Cleanup: remove event listeners, cancel timers
console.log('Unmounted')
})
onUpdated(() => {
// DOM has been updated after reactive state change
console.log('Updated')
})
// Less common
onBeforeMount(() => { /* Before initial render */ })
onBeforeUpdate(() => { /* Before DOM update */ })
onBeforeUnmount(() => { /* Before unmounting */ })
// Error handling
onErrorCaptured((err, instance, info) => {
console.error('Error captured:', err)
return false // Prevent propagation
})
// Keep-alive specific
onActivated(() => { /* Component activated from cache */ })
onDeactivated(() => { /* Component deactivated to cache */ })
Component Patterns
Props
// Type-based declaration (preferred)
interface Props {
title: string
count?: number
items: Item[]
onUpdate?: (value: string) => void
}
const props = defineProps<Props>()
// With defaults
const props = withDefaults(defineProps<Props>(), {
count: 0,
items: () => [] // Factory function for objects/arrays
})
// Destructuring with defaults
const { title, count = 0 } = defineProps<Props>()
One-way data flow: Props flow down, events flow up. Never mutate props directly.
// BAD - mutating prop
props.count++
// GOOD - emit event to parent
const emit = defineEmits<{ update: [count: number] }>()
emit('update', props.count + 1)
Emits
// Type-based declaration
const emit = defineEmits<{
change: [id: number]
update: [id: number, value: string]
'update:modelValue': [value: string]
}>()
// Usage
emit('change', 1)
emit('update', 1, 'new value')
Slots
Parent component:
<template>
<MyComponent>
<!-- Default slot -->
<p>Default content</p>
<!-- Named slot -->
<template #header>
<h1>Header</h1>
</template>
<!-- Scoped slot -->
<template #item="{ item, index }">
<li>{{ index }}: {{ item.name }}</li>
</template>
</MyComponent>
</template>
Child component:
<script setup lang="ts">
interface Item {
name: string
}
defineSlots<{
default(): any
header(): any
item(props: { item: Item; index: number }): any
}>()
</script>
<template>
<div>
<header>
<slot name="header" />
</header>
<main>
<slot /> <!-- default slot -->
</main>
<ul>
<li v-for="(item, index) in items" :key="item.id">
<slot name="item" :item="item" :index="index" />
</li>
</ul>
</div>
</template>
Provide/Inject
Dependency injection for deeply nested components:
// Parent component
import { provide, ref } from 'vue'
import type { InjectionKey } from 'vue'
// Type-safe injection key
const ThemeKey: InjectionKey<Ref<'light' | 'dark'>> = Symbol('theme')
const theme = ref<'light' | 'dark'>('light')
provide(ThemeKey, theme)
// Also provide mutation function
provide('setTheme', (newTheme: 'light' | 'dark') => {
theme.value = newTheme
})
// Child component (any depth)
import { inject } from 'vue'
const theme = inject(ThemeKey) // Ref<'light' | 'dark'> | undefined
const theme = inject(ThemeKey, ref('light')) // With default
const setTheme = inject<(theme: 'light' | 'dark') => void>('setTheme')
TypeScript Integration
Typing Refs
import { ref, type Ref } from 'vue'
// Inferred
const count = ref(0) // Ref<number>
// Explicit
const user = ref<User | null>(null)
// Complex types
interface State {
items: Item[]
loading: boolean
}
const state = ref<State>({ items: [], loading: false })
Typing Reactive
import { reactive } from 'vue'
interface State {
count: number
user: User | null
}
// Use interface annotation
const state: State = reactive({
count: 0,
user: null
})
Typing Computed
import { computed, type ComputedRef } from 'vue'
// Usually inferred
const doubled = computed(() => count.value * 2) // ComputedRef<number>
// Explicit when needed
const user: ComputedRef<User | undefined> = computed(() =>
users.value.find(u => u.id === selectedId.value)
)
Typing Event Handlers
function handleInput(event: Event) {
const target = event.target as HTMLInputElement
console.log(target.value)
}
function handleClick(event: MouseEvent) {
console.log(event.clientX, event.clientY)
}
Typing Template Refs
import { ref, onMounted } from 'vue'
const inputRef = ref<HTMLInputElement | null>(null)
const componentRef = ref<InstanceType<typeof MyComponent> | null>(null)
onMounted(() => {
inputRef.value?.focus()
componentRef.value?.someMethod()
})
<template>
<input ref="inputRef" />
<MyComponent ref="componentRef" />
</template>
Composables
Composables are functions that encapsulate and reuse stateful logic using Composition API.
Naming Convention
Always prefix with use: useMouse, useFetch, useAuth
Creating a Composable
// composables/useMouse.ts
import { ref, onMounted, onUnmounted } from 'vue'
export function useMouse() {
const x = ref(0)
const y = ref(0)
function update(event: MouseEvent) {
x.value = event.pageX
y.value = event.pageY
}
onMounted(() => window.addEventListener('mousemove', update))
onUnmounted(() => window.removeEventListener('mousemove', update))
return { x, y }
}
// Usage in component
import { useMouse } from '@/composables/useMouse'
const { x, y } = useMouse()
Composable with Arguments
// composables/useFetch.ts
import { ref, watchEffect, toValue, type MaybeRefOrGetter } from 'vue'
export function useFetch<T>(url: MaybeRefOrGetter<string>) {
const data = ref<T | null>(null)
const error = ref<Error | null>(null)
const loading = ref(false)
async function fetchData() {
loading.value = true
error.value = null
try {
const response = await fetch(toValue(url))
data.value = await response.json()
} catch (e) {
error.value = e instanceof Error ? e : new Error('Unknown error')
} finally {
loading.value = false
}
}
watchEffect(() => {
fetchData()
})
return { data, error, loading, refetch: fetchData }
}
Best Practices
- Return refs, not reactive objects - Allows destructuring while maintaining reactivity
- Accept
MaybeRefOrGetter- UsetoValue()to handle refs, getters, or plain values - Clean up side effects - Use
onUnmountedor cleanup callbacks - Call synchronously in setup - Don't call composables inside callbacks or conditions
Best Practices
Prefer ref() over reactive()
// Preferred
const count = ref(0)
const user = ref<User | null>(null)
// Avoid unless you have a specific reason
const state = reactive({ count: 0, user: null })
Keep Computed Getters Pure
// BAD
const doubled = computed(() => {
fetch('/api/log') // Side effect!
return count.value * 2
})
// GOOD
const doubled = computed(() => count.value * 2)
Use Descriptive Variable Names
// BAD
const d = ref<User[]>([])
// GOOD
const users = ref<User[]>([])
const isLoading = ref(false)
const hasError = ref(false)
Avoid Mutating Props
// BAD
props.items.push(newItem)
// GOOD
emit('add-item', newItem)
Type Everything
// BAD
const props = defineProps(['title', 'count'])
// GOOD
const props = defineProps<{
title: string
count?: number
}>()
Co-locate Related Logic
// Group related state, computed, and methods together
// Feature 1: User management
const user = ref<User | null>(null)
const isLoggedIn = computed(() => user.value !== null)
async function login(credentials: Credentials) { /* ... */ }
async function logout() { /* ... */ }
// Feature 2: Theme
const theme = ref<'light' | 'dark'>('light')
const isDarkMode = computed(() => theme.value === 'dark')
function toggleTheme() { /* ... */ }
Common Patterns
Async Component Loading
import { defineAsyncComponent } from 'vue'
const AsyncModal = defineAsyncComponent(() =>
import('./components/Modal.vue')
)
const AsyncModalWithOptions = defineAsyncComponent({
loader: () => import('./components/Modal.vue'),
loadingComponent: LoadingSpinner,
errorComponent: ErrorDisplay,
delay: 200,
timeout: 3000
})
v-model with Components
<!-- Parent -->
<CustomInput v-model="searchQuery" />
<CustomInput v-model:title="title" v-model:content="content" />
<!-- CustomInput.vue -->
<script setup lang="ts">
const model = defineModel<string>({ required: true })
</script>
<template>
<input :value="model" @input="model = ($event.target as HTMLInputElement).value" />
</template>
Conditional Rendering
<template>
<!-- v-if for conditional rendering (removes from DOM) -->
<div v-if="isLoading">Loading...</div>
<div v-else-if="error">Error: {{ error.message }}</div>
<div v-else>{{ data }}</div>
<!-- v-show for frequent toggling (CSS display: none) -->
<div v-show="isVisible">Toggleable content</div>
</template>
List Rendering
<template>
<ul>
<li v-for="item in items" :key="item.id">
{{ item.name }}
</li>
</ul>
<!-- With index -->
<li v-for="(item, index) in items" :key="item.id">
{{ index }}: {{ item.name }}
</li>
<!-- Object iteration -->
<div v-for="(value, key, index) in object" :key="key">
{{ key }}: {{ value }}
</div>
</template>
Event Handling
<template>
<!-- Method handler -->
<button @click="handleClick">Click</button>
<!-- Inline handler -->
<button @click="count++">Increment</button>
<!-- With event -->
<button @click="handleClick($event)">Click</button>
<!-- Modifiers -->
<button @click.stop="handleClick">Stop propagation</button>
<button @click.prevent="handleSubmit">Prevent default</button>
<input @keyup.enter="submit" />
<input @keyup.ctrl.enter="submitWithCtrl" />
</template>
Form Handling
<script setup lang="ts">
import { ref, reactive } from 'vue'
const form = reactive({
email: '',
password: '',
remember: false
})
const errors = ref<Record<string, string>>({})
async function handleSubmit() {
errors.value = {}
if (!form.email) {
errors.value.email = 'Email is required'
}
if (Object.keys(errors.value).length === 0) {
await submitForm(form)
}
}
</script>
<template>
<form @submit.prevent="handleSubmit">
<input v-model="form.email" type="email" />
<span v-if="errors.email">{{ errors.email }}</span>
<input v-model="form.password" type="password" />
<label>
<input v-model="form.remember" type="checkbox" />
Remember me
</label>
<button type="submit">Submit</button>
</form>
</template>
Related Resources
Core Tools
- VueUse Composables - Collection of essential Vue composition utilities
- Vite Configuration - Bundler configuration for Vue projects
TypeScript & Language Tooling
- Volar / Vue Language Tools - IDE support, TypeScript integration, and language server
- vue-tsc - Command-line type checking for Vue SFCs
Styling
- UnoCSS - Instant on-demand Atomic CSS engine (Tailwind-compatible)
UI Components
- UI Component Libraries - Comprehensive guide to Vue 3 UI ecosystem:
- Full suites (Vuetify, PrimeVue, Quasar, Naive UI)
- Headless libraries (Headless UI, Inspira UI)
- Specialized: tables, charts, forms, date pickers, editors
Data Visualization
- Chart.js - Flexible charting library with Vue 3 integration via vue-chartjs
Build Plugins
- UnPlugins - Essential Vue build plugins:
- unplugin-vue-router - File-based routing with TypeScript
- unplugin-vue-components - Auto-import components
- unplugin-vue-macros - Extended compiler macros
- unplugin-auto-import - Auto-import APIs
Static Site Generation
- Vite SSG - Static site generation for Vue 3 on Vite