Back to blog
vuejsbeginner

Vue.js Fundamentals — Components, Reactivity & Composition API

Build your first Vue 3 app — components, reactive state with the Composition API, directives, props, events, routing, and Pinia state management.

LearnixoApril 16, 20266 min read
Vue.jsVue 3Composition APIPiniaBeginner
Share:𝕏

Vue 3 is the approachable, performant, and versatile JavaScript framework. Simpler than Angular, more opinionated than React's ecosystem. With the Composition API, it scales from a simple widget to a full production SPA.


Setup

Bash
npm create vue@latest my-app
# Select: TypeScript, Router, Pinia, ESLint

cd my-app
npm install
npm run dev

Your First Component

src/components/UserCard.vue

VUE
<script setup lang="ts">
// Composition API — everything in <script setup>
import { ref, computed } from 'vue'

// Props (reactive, typed)
const props = defineProps<{
  name: string
  email: string
  role?: string
}>()

// Reactive state
const isExpanded = ref(false)

// Computed property
const displayRole = computed(() =>
  props.role ? props.role.toUpperCase() : 'USER'
)

// Emits
const emit = defineEmits<{
  select: [userId: string]
}>()
</script>

<template>
  <div class="card" @click="isExpanded = !isExpanded">
    <h3>{{ name }}</h3>
    <p>{{ email }}</p>
    <span class="badge">{{ displayRole }}</span>

    <div v-if="isExpanded" class="details">
      <slot />  <!-- content projection (like React children) -->
    </div>
  </div>
</template>

<style scoped>
.card { border: 1px solid #e2e8f0; padding: 1rem; border-radius: 8px; }
.badge { background: #7c3aed; color: white; padding: 2px 8px; border-radius: 4px; }
</style>

Reactivity — ref and reactive

TYPESCRIPT
import { ref, reactive, computed, watch } from 'vue'

// ref — for primitives (and objects when you want .value)
const count  = ref(0)
const name   = ref('Alice')
const isOpen = ref(false)

count.value++               // access with .value in script
// In template: {{ count }}  ← no .value needed

// reactive — for objects (deep reactivity)
const user = reactive({
  name:  'Alice',
  email: 'alice@example.com',
  role:  'admin',
})
user.name = 'Alice Smith'   // direct mutation — reactive tracks it

// computed — derived state, memoised
const fullName = computed(() => `${user.firstName} ${user.lastName}`)

// watch — side effects when state changes
watch(count, (newVal, oldVal) => {
  console.log(`count: ${oldVal}${newVal}`)
})

// watchEffect — runs immediately and re-runs when dependencies change
watchEffect(() => {
  console.log('Name changed:', name.value)
})

Template Directives

VUE
<template>
  <!-- v-if / v-else-if / v-else  conditional rendering (removes from DOM) -->
  <p v-if="isLoggedIn">Welcome back!</p>
  <p v-else>Please log in</p>

  <!-- v-show  hides (display:none), stays in DOM -->
  <div v-show="isVisible">This is hidden when false</div>

  <!-- v-for  list rendering -->
  <ul>
    <li v-for="item in items" :key="item.id">
      {{ item.name }}
    </li>
  </ul>

  <!-- v-model  two-way binding on inputs -->
  <input v-model="searchTerm" type="text" placeholder="Search..." />

  <!-- v-bind (:)  bind attribute to expression -->
  <img :src="imageUrl" :alt="altText" />
  <button :disabled="isLoading">Submit</button>

  <!-- v-on (@)  event listener -->
  <button @click="handleClick">Click me</button>
  <form @submit.prevent="handleSubmit">...</form>

  <!-- Dynamic class/style -->
  <div :class="{ active: isActive, 'text-red': hasError }">...</div>
  <div :style="{ color: textColor, fontSize: fontSize + 'px' }">...</div>
</template>

Props & Emits

VUE
<!-- Parent -->
<template>
  <ProductCard
    :product="selectedProduct"
    :show-price="true"
    @add-to-cart="handleAddToCart"
    @remove="handleRemove"
  />
</template>
VUE
<!-- Child (ProductCard.vue) -->
<script setup lang="ts">
interface Product { id: number; name: string; price: number }

const props = defineProps<{
  product: Product
  showPrice?: boolean
}>()

// Default values
withDefaults(defineProps<{ showPrice?: boolean }>(), {
  showPrice: true,
})

const emit = defineEmits<{
  addToCart: [product: Product]
  remove: [productId: number]
}>()

function addToCart() {
  emit('addToCart', props.product)
}
</script>

Composables — Reusable Logic

Composables are the Vue equivalent of React hooks — functions that use Vue's reactivity APIs:

TYPESCRIPT
// src/composables/useFetch.ts
import { ref, watchEffect } from 'vue'

export function useFetch<T>(url: string) {
  const data      = ref<T | null>(null)
  const isLoading = ref(false)
  const error     = ref<string | null>(null)

  watchEffect(async () => {
    isLoading.value = true
    error.value     = null
    try {
      const res  = await fetch(url)
      if (!res.ok) throw new Error(`HTTP ${res.status}`)
      data.value = await res.json()
    } catch (e) {
      error.value = (e as Error).message
    } finally {
      isLoading.value = false
    }
  })

  return { data, isLoading, error }
}

Usage in a component:

VUE
<script setup>
import { useFetch } from '@/composables/useFetch'

const { data: products, isLoading, error } = useFetch<Product[]>('/api/products')
</script>

<template>
  <div v-if="isLoading">Loading...</div>
  <div v-else-if="error">{{ error }}</div>
  <ul v-else>
    <li v-for="p in products" :key="p.id">{{ p.name }}</li>
  </ul>
</template>

Vue Router (Basics)

TYPESCRIPT
// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'

const router = createRouter({
  history: createWebHistory(),
  routes: [
    { path: '/',              component: () => import('@/views/HomeView.vue') },
    { path: '/products',      component: () => import('@/views/ProductsView.vue') },
    { path: '/products/:id',  component: () => import('@/views/ProductDetailView.vue') },
    { path: '/:pathMatch(.*)*', component: () => import('@/views/NotFoundView.vue') },
  ],
})

// Navigation guard
router.beforeEach((to, from) => {
  const isAuth = useAuthStore().isLoggedIn
  if (to.meta.requiresAuth && !isAuth) {
    return { path: '/login', query: { redirect: to.fullPath } }
  }
})

export default router
VUE
<!-- In templates -->
<RouterLink to="/products">Products</RouterLink>
<RouterLink :to="{ path: '/products', query: { category: 'electronics' } }">
  Electronics
</RouterLink>
TYPESCRIPT
// In script
import { useRouter, useRoute } from 'vue-router'

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

const productId = route.params.id   // path param
const page      = route.query.page  // query string

router.push('/products')
router.push({ path: '/products', query: { page: 2 } })

Pinia State Management

TYPESCRIPT
// src/stores/cart.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useCartStore = defineStore('cart', () => {
  // State
  const items = ref<CartItem[]>([])

  // Getters (computed)
  const total = computed(() =>
    items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
  )
  const count = computed(() =>
    items.value.reduce((sum, item) => sum + item.quantity, 0)
  )

  // Actions
  function add(product: Product, qty = 1) {
    const existing = items.value.find(i => i.productId === product.id)
    if (existing) {
      existing.quantity += qty
    } else {
      items.value.push({ productId: product.id, name: product.name, price: product.price, quantity: qty })
    }
  }

  function remove(productId: number) {
    items.value = items.value.filter(i => i.productId !== productId)
  }

  function clear() {
    items.value = []
  }

  return { items, total, count, add, remove, clear }
})

Usage:

VUE
<script setup>
import { useCartStore } from '@/stores/cart'
const cart = useCartStore()
</script>

<template>
  <p>Cart ({{ cart.count }}) — Total: {{ cart.total }}</p>
  <button @click="cart.add(product)">Add to Cart</button>
</template>

Quick Reference

Create app:      npm create vue@latest
Component: