| 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
const props = defineProps({
title: { type: String, required: true },
count: { type: Number, default: 0 }
})
const props = defineProps<{
title: string
count?: number
}>()
const props = withDefaults(defineProps<{
title: string
count?: number
}>(), {
count: 0
})
const { title, count = 0 } = defineProps<{
title: string
count?: number
}>()
defineEmits
const emit = defineEmits(['update', 'delete'])
const emit = defineEmits<{
update: [id: number, value: string]
delete: [id: number]
}>()
emit('update', 1, 'new value')
defineModel
Creates two-way binding props for v-model:
const modelValue = defineModel<string>()
modelValue.value = 'hello'
const count = defineModel<number>('count', { default: 0 })
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 = () => { }
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)
count.value++
user.value = { name: 'Ken' }
Advantages:
- Works with primitives and objects
- Can be passed to functions while retaining reactivity
- Explicit
.value makes reactivity visible
reactive()
Makes objects deeply reactive without wrapping:
import { reactive } from 'vue'
const state = reactive({
count: 0,
user: { name: 'Ken' }
})
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 } = state is not reactive
computed()
Creates cached derived values:
import { ref, computed } from 'vue'
const firstName = ref('Ken')
const lastName = ref('Snyder')
const fullName = computed(() => `${firstName.value} ${lastName.value}`)
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(count, (newValue, oldValue) => {
console.log(`Count changed: ${oldValue} -> ${newValue}`)
})
watch([count, user], ([newCount, newUser], [oldCount, oldUser]) => {
})
watch(
() => user.value?.name,
(newName) => console.log(`Name: ${newName}`)
)
watch(count, callback, {
immediate: true,
deep: true,
once: true,
flush: 'post'
})
const stop = watch(count, callback)
stop()
watchEffect()
Automatically tracks all reactive dependencies accessed during execution:
import { ref, watchEffect } from 'vue'
const count = ref(0)
const name = ref('Ken')
watchEffect(() => {
console.log(`Count: ${count.value}, Name: ${name.value}`)
})
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 dependencies
watchEffect(): 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()
}
Lifecycle Hooks
All hooks must be called synchronously during setup():
import {
onBeforeMount,
onMounted,
onBeforeUpdate,
onUpdated,
onBeforeUnmount,
onUnmounted,
onErrorCaptured,
onActivated,
onDeactivated
} from 'vue'
onMounted(() => {
console.log('Mounted')
})
onUnmounted(() => {
console.log('Unmounted')
})
onUpdated(() => {
console.log('Updated')
})
onBeforeMount(() => { })
onBeforeUpdate(() => { })
onBeforeUnmount(() => { })
onErrorCaptured((err, instance, info) => {
console.error('Error captured:', err)
return false
})
onActivated(() => { })
onDeactivated(() => { })
Component Patterns
Props
interface Props {
title: string
count?: number
items: Item[]
onUpdate?: (value: string) => void
}
const props = defineProps<Props>()
const props = withDefaults(defineProps<Props>(), {
count: 0,
items: () => []
})
const { title, count = 0 } = defineProps<Props>()
One-way data flow: Props flow down, events flow up. Never mutate props directly.
props.count++
const emit = defineEmits<{ update: [count: number] }>()
emit('update', props.count + 1)
Emits
const emit = defineEmits<{
change: [id: number]
update: [id: number, value: string]
'update:modelValue': [value: string]
}>()
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:
import { provide, ref } from 'vue'
import type { InjectionKey } from 'vue'
const ThemeKey: InjectionKey<Ref<'light' | 'dark'>> = Symbol('theme')
const theme = ref<'light' | 'dark'>('light')
provide(ThemeKey, theme)
provide('setTheme', (newTheme: 'light' | 'dark') => {
theme.value = newTheme
})
import { inject } from 'vue'
const theme = inject(ThemeKey)
const theme = inject(ThemeKey, ref('light'))
const setTheme = inject<(theme: 'light' | 'dark') => void>('setTheme')
TypeScript Integration
Typing Refs
import { ref, type Ref } from 'vue'
const count = ref(0)
const user = ref<User | null>(null)
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
}
const state: State = reactive({
count: 0,
user: null
})
Typing Computed
import { computed, type ComputedRef } from 'vue'
const doubled = computed(() => count.value * 2)
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
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 }
}
import { useMouse } from '@/composables/useMouse'
const { x, y } = useMouse()
Composable with Arguments
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 - Use toValue() to handle refs, getters, or plain values
- Clean up side effects - Use
onUnmounted or cleanup callbacks
- Call synchronously in setup - Don't call composables inside callbacks or conditions
Best Practices
Prefer ref() over reactive()
const count = ref(0)
const user = ref<User | null>(null)
const state = reactive({ count: 0, user: null })
Keep Computed Getters Pure
const doubled = computed(() => {
fetch('/api/log')
return count.value * 2
})
const doubled = computed(() => count.value * 2)
Use Descriptive Variable Names
const d = ref<User[]>([])
const users = ref<User[]>([])
const isLoading = ref(false)
const hasError = ref(false)
Avoid Mutating Props
props.items.push(newItem)
emit('add-item', newItem)
Type Everything
const props = defineProps(['title', 'count'])
const props = defineProps<{
title: string
count?: number
}>()
Co-locate Related Logic
const user = ref<User | null>(null)
const isLoggedIn = computed(() => user.value !== null)
async function login(credentials: Credentials) { }
async function logout() { }
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
TypeScript & Language Tooling
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