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.

Counter.vuevue
<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.

reactivity.tstypescript
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.

ChildComponent.vuevue
<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.

useFetch.tstypescript
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.

authStore.tstypescript
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.

ListLayout.vuevue
<!-- 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>