Vue.js

Essential Vue.js 3 syntax, Composition API, and best practices for building reactive applications.

frameworks
vuejavascriptfrontendspa

Component Basics

<script setup>
// Composition API with <script setup>
import { ref } from 'vue'

// Props
const props = defineProps({
  title: String,
  count: {
    type: Number,
    default: 0
  }
})

// Emits
const emit = defineEmits(['update', 'delete'])

// Reactive state
const message = ref('Hello!')

// Methods
function handleClick() {
  emit('update', message.value)
}
</script>

<template>
  <div>
    <h1>{{ title }}</h1>
    <p>{{ message }}</p>
    <button @click="handleClick">Update</button>
  </div>
</template>

<style scoped>
h1 {
  color: #333;
}
</style>

Reactive State

<script setup>
import { ref, reactive, computed, watch, watchEffect } from 'vue'

// ref for primitives
const count = ref(0)
const name = ref('Vue')

// Access/modify ref value
console.log(count.value)
count.value++

// reactive for objects
const state = reactive({
  user: { name: 'John', age: 30 },
  items: []
})

// Direct mutation
state.user.name = 'Jane'
state.items.push({ id: 1 })

// Computed properties
const doubleCount = computed(() => count.value * 2)

// Writable computed
const fullName = computed({
  get: () => `${state.user.name}`,
  set: (value) => { state.user.name = value }
})

// Watch single ref
watch(count, (newVal, oldVal) => {
  console.log(`Count changed: ${oldVal} -> ${newVal}`)
})

// Watch reactive object property
watch(
  () => state.user.name,
  (newName) => console.log('Name changed:', newName)
)

// Watch multiple sources
watch([count, name], ([newCount, newName]) => {
  console.log('Values:', newCount, newName)
})

// Immediate watch
watch(count, (val) => console.log(val), { immediate: true })

// watchEffect - auto-tracks dependencies
watchEffect(() => {
  console.log('Count is:', count.value)
})
</script>

Template Syntax

<template>
  <!-- Text interpolation -->
  <p>{{ message }}</p>
  <p>{{ user.name }}</p>
  <p>{{ formatDate(date) }}</p>
  
  <!-- Raw HTML -->
  <div v-html="rawHtml"></div>
  
  <!-- Attribute binding -->
  <img :src="imageUrl" :alt="imageAlt">
  <button :disabled="isDisabled">Click</button>
  
  <!-- Dynamic attribute name -->
  <a :[attributeName]="url">Link</a>
  
  <!-- Class binding -->
  <div :class="{ active: isActive, disabled: isDisabled }"></div>
  <div :class="[baseClass, { active: isActive }]"></div>
  
  <!-- Style binding -->
  <div :style="{ color: textColor, fontSize: size + 'px' }"></div>
  <div :style="[baseStyles, overrideStyles]"></div>
  
  <!-- Event handling -->
  <button @click="handleClick">Click</button>
  <button @click="count++">Increment</button>
  <input @input="onInput($event)">
  <form @submit.prevent="onSubmit">
  
  <!-- Event modifiers -->
  <button @click.stop="onClick">Stop propagation</button>
  <button @click.once="onClick">Only once</button>
  <input @keyup.enter="submit">
  
  <!-- Two-way binding -->
  <input v-model="message">
  <input v-model.trim="message">
  <input v-model.number="age" type="number">
  <input v-model.lazy="message">
</template>

Conditional Rendering

<template>
  <!-- v-if / v-else-if / v-else -->
  <div v-if="type === 'A'">Type A</div>
  <div v-else-if="type === 'B'">Type B</div>
  <div v-else>Other type</div>
  
  <!-- v-show (toggles display CSS) -->
  <div v-show="isVisible">Always in DOM</div>
  
  <!-- v-if on template (no wrapper element) -->
  <template v-if="showDetails">
    <h1>Title</h1>
    <p>Description</p>
  </template>
</template>

List Rendering

<template>
  <!-- Array -->
  <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 -->
  <li v-for="(value, key, index) in object" :key="key">
    {{ key }}: {{ value }}
  </li>
  
  <!-- Range -->
  <span v-for="n in 10" :key="n">{{ n }}</span>
  
  <!-- With v-if (use template wrapper) -->
  <template v-for="item in items" :key="item.id">
    <li v-if="item.isActive">{{ item.name }}</li>
  </template>
</template>

Props & Emits

<script setup>
// Props with types and defaults
const props = defineProps({
  title: {
    type: String,
    required: true
  },
  count: {
    type: Number,
    default: 0
  },
  items: {
    type: Array,
    default: () => []
  },
  user: {
    type: Object,
    default: () => ({ name: 'Guest' })
  },
  callback: Function,
  status: {
    type: String,
    validator: (value) => ['active', 'inactive'].includes(value)
  }
})

// TypeScript props
interface Props {
  title: string
  count?: number
  items?: string[]
}
const props = defineProps<Props>()

// With defaults (TypeScript)
const props = withDefaults(defineProps<Props>(), {
  count: 0,
  items: () => []
})

// Emits
const emit = defineEmits(['update', 'delete'])
const emit = defineEmits<{
  (e: 'update', value: string): void
  (e: 'delete', id: number): void
}>()

// Usage
emit('update', 'new value')
emit('delete', 123)
</script>

Lifecycle Hooks

<script setup>
import { 
  onMounted, 
  onUpdated, 
  onUnmounted, 
  onBeforeMount,
  onBeforeUpdate,
  onBeforeUnmount 
} from 'vue'

// Before mount
onBeforeMount(() => {
  console.log('Before mount')
})

// After mount (DOM available)
onMounted(() => {
  console.log('Mounted')
  // Access DOM, start timers, fetch data
})

// Before update
onBeforeUpdate(() => {
  console.log('Before update')
})

// After update
onUpdated(() => {
  console.log('Updated')
})

// Before unmount
onBeforeUnmount(() => {
  console.log('Before unmount')
})

// After unmount (cleanup)
onUnmounted(() => {
  console.log('Unmounted')
  // Clean up timers, subscriptions
})
</script>

Template Refs

<script setup>
import { ref, onMounted } from 'vue'

// DOM element ref
const inputRef = ref(null)

// Component ref
const childRef = ref(null)

onMounted(() => {
  // Access DOM element
  inputRef.value.focus()
  
  // Access child component methods/properties
  childRef.value.someMethod()
})
</script>

<template>
  <input ref="inputRef">
  <ChildComponent ref="childRef" />
</template>

Composables (Custom Hooks)

// composables/useMouse.js
import { ref, onMounted, onUnmounted } from 'vue'

export function useMouse() {
  const x = ref(0)
  const y = ref(0)

  function update(event) {
    x.value = event.pageX
    y.value = event.pageY
  }

  onMounted(() => window.addEventListener('mousemove', update))
  onUnmounted(() => window.removeEventListener('mousemove', update))

  return { x, y }
}

// composables/useFetch.js
import { ref, watchEffect } from 'vue'

export function useFetch(url) {
  const data = ref(null)
  const error = ref(null)
  const loading = ref(true)

  watchEffect(async () => {
    loading.value = true
    try {
      const res = await fetch(url.value || url)
      data.value = await res.json()
    } catch (e) {
      error.value = e
    } finally {
      loading.value = false
    }
  })

  return { data, error, loading }
}

// Usage in component
import { useMouse } from '@/composables/useMouse'
import { useFetch } from '@/composables/useFetch'

const { x, y } = useMouse()
const { data, loading } = useFetch('/api/users')

Provide / Inject

<!-- Parent component -->
<script setup>
import { provide, ref } from 'vue'

const theme = ref('dark')
const updateTheme = (newTheme) => {
  theme.value = newTheme
}

// Provide to all descendants
provide('theme', theme)
provide('updateTheme', updateTheme)

// Or provide object
provide('themeContext', {
  theme,
  updateTheme
})
</script>

<!-- Child/Descendant component -->
<script setup>
import { inject } from 'vue'

// Inject with default value
const theme = inject('theme', 'light')
const updateTheme = inject('updateTheme')

// Or inject object
const { theme, updateTheme } = inject('themeContext')
</script>

Slots

<!-- Parent using child with slots -->
<template>
  <Card>
    <!-- Default slot -->
    <p>Main content</p>
    
    <!-- Named slots -->
    <template #header>
      <h1>Card Title</h1>
    </template>
    
    <template #footer>
      <button>Action</button>
    </template>
    
    <!-- Scoped slot -->
    <template #item="{ item, index }">
      <li>{{ index }}: {{ item.name }}</li>
    </template>
  </Card>
</template>

<!-- Card.vue (child with slots) -->
<template>
  <div class="card">
    <header>
      <slot name="header"></slot>
    </header>
    
    <main>
      <slot></slot> <!-- Default slot -->
    </main>
    
    <ul>
      <slot 
        v-for="(item, index) in items" 
        name="item" 
        :item="item" 
        :index="index"
      ></slot>
    </ul>
    
    <footer>
      <slot name="footer">Default footer</slot>
    </footer>
  </div>
</template>

Vue Router

// router/index.js
import { createRouter, createWebHistory } from 'vue-router'

const routes = [
  { path: '/', component: () => import('@/views/Home.vue') },
  { path: '/about', component: () => import('@/views/About.vue') },
  { 
    path: '/users/:id', 
    component: () => import('@/views/User.vue'),
    props: true 
  },
  { 
    path: '/admin', 
    component: () => import('@/views/Admin.vue'),
    meta: { requiresAuth: true }
  },
  { path: '/:pathMatch(.*)*', component: NotFound }
]

const router = createRouter({
  history: createWebHistory(),
  routes
})

// Navigation guard
router.beforeEach((to, from) => {
  if (to.meta.requiresAuth && !isAuthenticated) {
    return '/login'
  }
})

export default router
<!-- Using router in component -->
<script setup>
import { useRouter, useRoute } from 'vue-router'

const router = useRouter()
const route = useRoute()

// Get route params
const userId = route.params.id
const searchQuery = route.query.q

// Navigation
function goToUser(id) {
  router.push(`/users/${id}`)
  // Or with object
  router.push({ name: 'user', params: { id } })
}

function goBack() {
  router.back()
}
</script>

<template>
  <nav>
    <RouterLink to="/">Home</RouterLink>
    <RouterLink :to="{ name: 'about' }">About</RouterLink>
  </nav>
  
  <RouterView />
</template>

Pinia (State Management)

// stores/counter.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

// Composition API style
export const useCounterStore = defineStore('counter', () => {
  // State
  const count = ref(0)
  
  // Getters
  const doubleCount = computed(() => count.value * 2)
  
  // Actions
  function increment() {
    count.value++
  }
  
  async function fetchCount() {
    const res = await fetch('/api/count')
    count.value = await res.json()
  }
  
  return { count, doubleCount, increment, fetchCount }
})

// Options API style
export const useUserStore = defineStore('user', {
  state: () => ({
    name: '',
    isLoggedIn: false
  }),
  getters: {
    greeting: (state) => `Hello, ${state.name}!`
  },
  actions: {
    login(name) {
      this.name = name
      this.isLoggedIn = true
    },
    logout() {
      this.$reset()
    }
  }
})
<!-- Using Pinia store -->
<script setup>
import { useCounterStore } from '@/stores/counter'
import { storeToRefs } from 'pinia'

const store = useCounterStore()

// Destructure with reactivity preserved
const { count, doubleCount } = storeToRefs(store)

// Actions can be destructured directly
const { increment } = store
</script>

<template>
  <p>Count: {{ count }}</p>
  <p>Double: {{ doubleCount }}</p>
  <button @click="increment">+</button>
</template>

Async Components

<script setup>
import { defineAsyncComponent } from 'vue'

// Basic async component
const AsyncModal = defineAsyncComponent(() => 
  import('./components/Modal.vue')
)

// With loading and error states
const AsyncComponent = defineAsyncComponent({
  loader: () => import('./HeavyComponent.vue'),
  loadingComponent: LoadingSpinner,
  errorComponent: ErrorDisplay,
  delay: 200,
  timeout: 3000
})
</script>

<template>
  <Suspense>
    <template #default>
      <AsyncComponent />
    </template>
    <template #fallback>
      <div>Loading...</div>
    </template>
  </Suspense>
</template>

Teleport

<template>
  <!-- Render modal at body level -->
  <Teleport to="body">
    <div v-if="showModal" class="modal">
      <h2>Modal Title</h2>
      <button @click="showModal = false">Close</button>
    </div>
  </Teleport>
  
  <!-- Teleport to specific element -->
  <Teleport to="#modals">
    <Notification />
  </Teleport>
</template>