Skip to main content

Getting Started with Vue.js

Published: May 24, 2026 Updated: August 29, 2026 Larry Qu 5 min read

Vue.js is a progressive JavaScript framework for building user interfaces. “Progressive” means you can use as much or as little of it as you need — add it to a single page with a <script> tag, or build a full SPA with the Composition API, Vue Router, and Pinia.

Vue 3 with the Composition API is the current standard. This guide focuses on that.

Project Setup

npm create vite@latest my-app -- --template vue
cd my-app && npm install && npm run dev

Vite provides instant hot module reload during development and fast production builds.

Reactive Templates

A Single-File Component (.vue) packages template, script, and style together. The <script setup> syntax is the idiomatic Composition API form — variables declared here are automatically available in the template:

<!-- Counter.vue -->
<template>
  <div>
    <p>Count: {{ count }}</p>
    <p>Double: {{ double }}</p>
    <button @click="increment">+1</button>
    <button @click="reset">Reset</button>
  </div>
</template>

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

const count = ref(0)

// computed — automatically re-evaluates when count changes
const double = computed(() => count.value * 2)

const increment = () => count.value++
const reset = () => { count.value = 0 }
</script>

ref(0) creates a reactive value. Inside <script setup> you access it as count.value; in the template, Vue unwraps it automatically so you just write {{ count }}.

computed vs watch: Use computed when you need a derived value (something that transforms existing state). Use watch when you need a side effect (fetching data, logging, calling an API) in response to state changes:

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

const searchQuery = ref('')
const results = ref([])

// watch triggers a side effect when searchQuery changes
watch(searchQuery, async (newQuery, oldQuery) => {
  if (!newQuery) {
    results.value = []
    return
  }
  const data = await fetch(`/api/search?q=${newQuery}`).then(r => r.json())
  results.value = data
})
</script>

Components: Props and Emits

Components receive data via props and communicate upward via emits. This one-way data flow makes components predictable:

<!-- UserCard.vue -->
<template>
  <div class="card">
    <h3>{{ user.name }}</h3>
    <p>{{ user.email }}</p>
    <button @click="$emit('edit', user.id)">Edit</button>
    <button @click="$emit('delete', user.id)">Delete</button>
  </div>
</template>

<script setup>
// defineProps and defineEmits are compiler macros — no import needed
const props = defineProps({
  user: {
    type: Object,
    required: true,
  },
})

const emit = defineEmits(['edit', 'delete'])
</script>

Using the component:

<!-- UserList.vue -->
<template>
  <UserCard
    v-for="user in users"
    :key="user.id"
    :user="user"
    @edit="handleEdit"
    @delete="handleDelete"
  />
</template>

<script setup>
import { ref } from 'vue'
import UserCard from './UserCard.vue'

const users = ref([
  { id: 1, name: 'Alice', email: '[email protected]' },
  { id: 2, name: 'Bob',   email: '[email protected]' },
])

const handleEdit   = (id) => console.log('edit', id)
const handleDelete = (id) => {
  users.value = users.value.filter(u => u.id !== id)
}
</script>

Always provide :key on v-for — Vue uses it to efficiently update the DOM when the list changes.

Composables: Reusable Logic

The Composition API’s biggest advantage is composables — functions that encapsulate stateful logic and can be shared across components:

// composables/useAsync.js
import { ref } from 'vue'

export function useAsync(asyncFn) {
  const data = ref(null)
  const loading = ref(false)
  const error = ref(null)

  const execute = async (...args) => {
    loading.value = true
    error.value = null
    try {
      data.value = await asyncFn(...args)
    } catch (err) {
      error.value = err.message
    } finally {
      loading.value = false
    }
  }

  return { data, loading, error, execute }
}
<!-- UserProfile.vue -->
<template>
  <div v-if="loading">Loading...</div>
  <div v-else-if="error">Error: {{ error }}</div>
  <div v-else-if="data">
    <h1>{{ data.name }}</h1>
    <p>{{ data.email }}</p>
  </div>
</template>

<script setup>
import { onMounted } from 'vue'
import { useAsync } from '@/composables/useAsync'

const props = defineProps({ userId: String })

const { data, loading, error, execute } = useAsync(
  (id) => fetch(`/api/users/${id}`).then(r => r.json())
)

onMounted(() => execute(props.userId))
</script>

Composables replace mixins — they’re explicit about what they provide and don’t pollute the component namespace.

Vue Router

Install and configure routing:

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

const routes = [
  { path: '/',        component: () => import('@/pages/Home.vue') },
  { path: '/users',   component: () => import('@/pages/UserList.vue') },
  { path: '/users/:id', component: () => import('@/pages/UserDetail.vue') },
  {
    path: '/admin',
    component: () => import('@/pages/Admin.vue'),
    meta: { requiresAuth: true },
  },
]

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

// Navigation guard — runs before every route
router.beforeEach((to, from) => {
  if (to.meta.requiresAuth && !isLoggedIn()) {
    return { path: '/login', query: { redirect: to.fullPath } }
  }
})

export default router
<!-- App.vue — navigation and router outlet -->
<template>
  <nav>
    <RouterLink to="/">Home</RouterLink>
    <RouterLink to="/users">Users</RouterLink>
  </nav>
  <RouterView />
</template>

The () => import(...) syntax enables code splitting — each route’s component is loaded only when navigated to, keeping the initial bundle small.

Pinia: State Management

For state shared across multiple components that aren’t parent-child related, use Pinia (Vue 3’s official state management library):

npm install pinia
// stores/users.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useUsersStore = defineStore('users', () => {
  const users = ref([])
  const loading = ref(false)

  const activeUsers = computed(() => users.value.filter(u => u.active))

  async function fetchUsers() {
    loading.value = true
    try {
      users.value = await fetch('/api/users').then(r => r.json())
    } finally {
      loading.value = false
    }
  }

  function removeUser(id) {
    users.value = users.value.filter(u => u.id !== id)
  }

  return { users, loading, activeUsers, fetchUsers, removeUser }
})
<script setup>
import { onMounted } from 'vue'
import { useUsersStore } from '@/stores/users'

const store = useUsersStore()
onMounted(() => store.fetchUsers())
</script>

<template>
  <div v-if="store.loading">Loading...</div>
  <UserCard
    v-for="user in store.activeUsers"
    :key="user.id"
    :user="user"
    @delete="store.removeUser"
  />
</template>

Pinia stores are reactive — any component that reads store state will re-render when that state changes.

Summary

  • ref() for reactive primitives, computed() for derived values, watch() for side effects
  • Props flow down, emits go up — keep data flow one-directional
  • Composables extract and reuse stateful logic across components
  • Vue Router with lazy-loaded routes keeps the initial bundle small
  • Pinia for shared state that doesn’t fit the parent-child component relationship

Resources

Comments

👍 Was this article helpful?