Vue 3's Composition API transformed how we build Vue apps, offering superior TypeScript support and logical reuse compared to the Options API. Combined with Pinia and Vue Router, it forms a powerful ecosystem. This guide covers modern Vue 3 patterns using <script setup>.
Step 1 — Vue 3 Fundamentals: <script setup>
<script setup> is a compile-time syntactic sugar for Composition API. Variables and imports declared inside it are automatically exposed to the template.
<script setup lang="ts">
import { ref, computed } from 'vue'
// Reactive state
const count = ref(0)
// Computed property (cached, auto-updates)
const double = computed(() => count.value * 2)
// Function
function increment() {
count.value++
}
</script>
<template>
<div>
<!-- Auto-unwraps ref in template, no .value needed -->
<p>Count: {{ count }}, Double: {{ double }}</p>
<button @click="increment">Increment</button>
<!-- Two-way binding -->
<input v-model="count" type="number" />
</div>
</template>Step 2 — Reactivity Deep Dive
Vue's reactivity is based on ES6 Proxies. It tracks dependencies automatically when variables are read, and triggers updates when they are mutated.
import { ref, reactive, watch, watchEffect } from 'vue'
// ref: Use for primitives (string, number, boolean) or reassigned objects
const name = ref('Vue')
name.value = 'Vue 3' // Must use .value in JS/TS
// reactive: Use for objects/arrays. Returns a Proxy.
// Note: You cannot reassign the whole object, or it loses reactivity.
const state = reactive({
user: 'Kuldeep',
score: 10
})
state.score++ // No .value needed
// watchEffect: Runs immediately, auto-tracks any reactive variables inside
watchEffect(() => {
console.log(`User ${state.user} has score ${state.score}`)
})
// watch: Specific tracking, access to old/new values, lazy by default
watch(
() => state.score, // getter function
(newScore, oldScore) => {
if (newScore > 100) console.log('Level up!')
},
{ deep: true, immediate: false }
)Step 3 — Component Communication
Data flows down via Props, and events flow up via Emits. Vue 3.4+ introduces defineModel for extremely simple two-way binding.
<script setup lang="ts">
// 1. Props with TypeScript
defineProps<{
title: string;
count?: number; // Optional prop
}>()
// 2. Emits with TypeScript
const emit = defineEmits<{
(e: 'update', id: number): void;
(e: 'delete'): void;
}>()
// 3. New Vue 3.4+ defineModel macro for two-way binding
// This creates a prop + emit pair under the hood
const modelValue = defineModel<string>()
</script>
<template>
<div>
<h2>{{ title }}</h2>
<button @click="emit('update', 42)">Update</button>
<input v-model="modelValue" />
</div>
</template>Step 4 — Composables (Custom Hooks)
Composables are functions that leverage Vue's Composition API to encapsulate and reuse stateful logic. This replaces Mixins from Vue 2.
import { ref, watchEffect, toValue, MaybeRefOrGetter } from 'vue'
// Accepts ref, getter, or raw string
export function useFetch(url: MaybeRefOrGetter<string>) {
const data = ref<any>(null)
const error = ref<Error | null>(null)
const isPending = ref(true)
watchEffect(async () => {
isPending.value = true
error.value = null
try {
// toValue unwraps the ref/getter
const res = await fetch(toValue(url))
data.value = await res.json()
} catch (err: any) {
error.value = err
} finally {
isPending.value = false
}
})
return { data, error, isPending }
}Step 5 — Global State with Pinia
Pinia is the official state management library, replacing Vuex. It is fully typed, requires no mutations, and supports Setup Stores.
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
// Setup Store syntax (looks just like a Composition API component)
export const useAuthStore = defineStore('auth', () => {
// State (refs)
const user = ref<{ id: number; name: string } | null>(null)
const token = ref<string | null>(null)
// Getters (computed)
const isAuthenticated = computed(() => !!token.value)
// Actions (functions)
async function login(creds: any) {
const res = await api.post('/login', creds)
user.value = res.user
token.value = res.token
}
function logout() {
user.value = null
token.value = null
}
// Return what should be exposed
return { user, token, isAuthenticated, login, logout }
})
// Usage in component:
// const authStore = useAuthStore()
// authStore.login(...)
// Use storeToRefs(authStore) if you need to destructure state without losing reactivity!Step 6 — Slots and Component Flexibility
Slots allow you to pass template fragments to components. Scoped slots allow the child component to pass data back to the parent's slot template.
<!-- Child: ListLayout.vue -->
<template>
<div class="layout">
<header><slot name="header">Default Header</slot></header>
<main>
<!-- Scoped slot: exposing 'item' to parent -->
<slot name="item" v-for="item in items" :key="item.id" :item="item" />
</main>
</div>
</template>
<!-- Parent.vue -->
<template>
<ListLayout :items="users">
<template #header>
<h1>User Directory</h1>
</template>
<!-- Destructuring the scoped slot props -->
<template #item="{ item }">
<div class="user-card">{{ item.name }}</div>
</template>
</ListLayout>
</template>