Vue.js: The Progressive Framework That Makes Sense on Day One
A practical guide to Vue.js — its reactivity system, Composition API, ecosystem, and why it remains the most approachable frontend framework.
There's a pattern in frontend development. A new framework arrives with bold promises. The documentation is written for people who already understand the framework. The "getting started" tutorial requires a build tool, a bundler, a state management library, and three VS Code extensions before you can render "Hello World." By the time you've configured everything, you've forgotten what you were trying to build.
Vue.js broke that pattern. When Evan You created Vue in 2014, he made a deliberate choice: the framework should be useful from the first minute. You can drop a script tag into an HTML file and start building. You can also scale it up to a full single-page application with routing, state management, and server-side rendering. The "progressive" in "progressive framework" isn't marketing — it's the actual design philosophy.
Why Vue Keeps Winning Developers
Vue occupies a unique position in the frontend landscape. It's not trying to be the most powerful framework (Angular has more built-in enterprise features). It's not trying to be the most popular (React has a larger ecosystem). What Vue does better than anyone else is balance power with simplicity.
The reactivity system is intuitive. The component model is clean. The documentation is genuinely excellent — consistently rated the best docs in the frontend ecosystem. And the learning curve is gentle enough that developers who know HTML, CSS, and basic JavaScript can build their first interactive application in an afternoon.
That approachability isn't a weakness. Companies like GitLab, Nintendo, Adobe, and Alibaba use Vue in production. It's not a toy — it just doesn't make you feel stupid while you learn it.
The Reactivity System
Vue's reactivity is the core concept that everything else builds on. When data changes, the UI updates. You don't manually call setState or dispatch actions — you just change the value.
With the Composition API (the modern way to write Vue):
<script setup>
import { ref, computed, watch } from 'vue'
// ref() creates a reactive value
const count = ref(0)
const name = ref('World')
// computed() derives values that auto-update
const greeting = computed(() => Hello, ${name.value}!)
const doubled = computed(() => count.value * 2)
// watch() reacts to changes
watch(count, (newValue, oldValue) => {
console.log(Count changed from ${oldValue} to ${newValue})
})
function increment() {
count.value++ // UI updates automatically
}
</script>
<template>
<h1>{{ greeting }}</h1>
<p>Count: {{ count }} (doubled: {{ doubled }})</p>
<button @click="increment">Add one</button>
<input v-model="name" placeholder="Your name" />
</template>
The .value accessor is the only gotcha. Inside , reactive refs need .value to access or set the underlying value. Inside , Vue automatically unwraps them, so you just write {{ count }} instead of {{ count.value }}. This trips up beginners for about a day, then it becomes second nature.
For reactive objects (as opposed to single values), use reactive():
<script setup>
import { reactive, computed } from 'vue'
const state = reactive({
todos: [],
filter: 'all',
newTodo: ''
})
const filteredTodos = computed(() => {
switch (state.filter) {
case 'active':
return state.todos.filter(t => !t.done)
case 'completed':
return state.todos.filter(t => t.done)
default:
return state.todos
}
})
function addTodo() {
if (state.newTodo.trim()) {
state.todos.push({
id: Date.now(),
text: state.newTodo.trim(),
done: false
})
state.newTodo = ''
}
}
function toggleTodo(id) {
const todo = state.todos.find(t => t.id === id)
if (todo) todo.done = !todo.done // directly mutate — Vue tracks it
}
</script>
Notice that you can directly mutate reactive objects. No immutable state updates, no spread operators, no reducer functions. Vue's reactivity system uses JavaScript Proxies to intercept mutations and trigger updates. Some developers find this uncomfortable if they come from React's immutability mindset. But it means less boilerplate and fewer bugs from forgetting to spread nested objects.
Components
Vue components are single-file components (SFCs) — files with a .vue extension that contain template, script, and style in one place:
<!-- UserCard.vue -->
<script setup>
// Props with types and defaults
const props = defineProps({
user: {
type: Object,
required: true
},
showEmail: {
type: Boolean,
default: false
}
})
// Events
const emit = defineEmits(['select', 'delete'])
function handleSelect() {
emit('select', props.user.id)
}
</script>
<template>
<div class="user-card" @click="handleSelect">
<img :src="user.avatar" :alt="user.name" class="avatar" />
<div class="info">
<h3>{{ user.name }}</h3>
<p v-if="showEmail">{{ user.email }}</p>
<span class="role">{{ user.role }}</span>
</div>
<button @click.stop="emit('delete', user.id)" class="delete-btn">
Remove
</button>
</div>
</template>
<style scoped>
.user-card {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
border: 1px solid #e2e8f0;
border-radius: 8px;
cursor: pointer;
transition: box-shadow 0.2s;
}
.user-card:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.avatar {
width: 48px;
height: 48px;
border-radius: 50%;
}
</style>
The section is a standout feature. Scoped styles only apply to the current component — no CSS leaking, no BEM naming conventions, no CSS modules to configure. Vue handles it by adding data attributes to elements and scoping selectors automatically.
Using this component in a parent:
<script setup>
import { ref } from 'vue'
import UserCard from './UserCard.vue'
const users = ref([
{ id: 1, name: 'Alice', email: 'alice@example.com', role: 'Admin', avatar: '/alice.jpg' },
{ id: 2, name: 'Bob', email: 'bob@example.com', role: 'Editor', avatar: '/bob.jpg' },
])
function handleSelect(userId) {
console.log('Selected user:', userId)
}
function handleDelete(userId) {
users.value = users.value.filter(u => u.id !== userId)
}
</script>
<template>
<div class="user-list">
<UserCard
v-for="user in users"
:key="user.id"
:user="user"
show-email
@select="handleSelect"
@delete="handleDelete"
/>
</div>
</template>
The template syntax is HTML with extras. v-for loops, v-if conditionally renders, :prop binds dynamically, @event listens. If you know HTML, you can read Vue templates without studying the docs first. That's by design.
Composables — Reusable Logic
Composables are Vue's answer to React hooks. They're plain functions that use Vue's reactivity to encapsulate and reuse stateful logic:
// composables/useFetch.js
import { ref, watchEffect } from 'vue'
export function useFetch(url) {
const data = ref(null)
const error = ref(null)
const loading = ref(true)
async function fetchData() {
loading.value = true
error.value = null
try {
const response = await fetch(url.value || url)
if (!response.ok) throw new Error(HTTP ${response.status})
data.value = await response.json()
} catch (e) {
error.value = e.message
} finally {
loading.value = false
}
}
// If url is a ref, refetch when it changes
if (typeof url !== 'string') {
watchEffect(fetchData)
} else {
fetchData()
}
return { data, error, loading, refetch: fetchData }
}
// composables/useLocalStorage.js
import { ref, watch } from 'vue'
export function useLocalStorage(key, defaultValue) {
const stored = localStorage.getItem(key)
const data = ref(stored ? JSON.parse(stored) : defaultValue)
watch(data, (newValue) => {
localStorage.setItem(key, JSON.stringify(newValue))
}, { deep: true })
return data
}
Using composables in a component:
<script setup>
import { ref, computed } from 'vue'
import { useFetch } from '@/composables/useFetch'
import { useLocalStorage } from '@/composables/useLocalStorage'
const searchQuery = ref('')
const { data: users, loading, error } = useFetch('/api/users')
const favoriteIds = useLocalStorage('favorites', [])
const filteredUsers = computed(() => {
if (!users.value) return []
return users.value.filter(u =>
u.name.toLowerCase().includes(searchQuery.value.toLowerCase())
)
})
function toggleFavorite(id) {
const index = favoriteIds.value.indexOf(id)
if (index === -1) {
favoriteIds.value.push(id)
} else {
favoriteIds.value.splice(index, 1)
}
}
</script>
Composables are simpler than React hooks because they don't have the same rules. No "rules of hooks" — you can call composables conditionally, in loops, or in nested functions. The only requirement is that they're called during setup() (which handles implicitly).
Vue Router
Vue Router is the official router, tightly integrated with Vue's reactivity:
// router/index.js
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
component: () => import('@/views/Home.vue')
},
{
path: '/users',
component: () => import('@/views/Users.vue'),
meta: { requiresAuth: true }
},
{
path: '/users/:id',
component: () => import('@/views/UserDetail.vue'),
props: true // passes route params as props
},
{
path: '/dashboard',
component: () => import('@/views/Dashboard.vue'),
children: [
{ path: '', component: () => import('@/views/DashboardHome.vue') },
{ path: 'settings', component: () => import('@/views/Settings.vue') },
]
},
{
path: '/:pathMatch(.)',
component: () => import('@/views/NotFound.vue')
}
]
})
// Navigation guard
router.beforeEach((to, from) => {
const isAuthenticated = !!localStorage.getItem('token')
if (to.meta.requiresAuth && !isAuthenticated) {
return { path: '/login', query: { redirect: to.fullPath } }
}
})
export default router
Lazy loading with () => import(...) means route components are only loaded when visited — automatic code splitting without configuration.
Pinia — State Management
Pinia replaced Vuex as Vue's official state management library. It's simpler, type-safe, and works naturally with the Composition API:
// stores/auth.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useAuthStore = defineStore('auth', () => {
const user = ref(null)
const token = ref(localStorage.getItem('token'))
const isAuthenticated = computed(() => !!token.value)
const isAdmin = computed(() => user.value?.role === 'admin')
async function login(email, password) {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
})
if (!response.ok) throw new Error('Login failed')
const data = await response.json()
user.value = data.user
token.value = data.token
localStorage.setItem('token', data.token)
}
function logout() {
user.value = null
token.value = null
localStorage.removeItem('token')
}
return { user, token, isAuthenticated, isAdmin, login, logout }
})
Using a store in a component:
<script setup>
import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore()
// auth.isAuthenticated, auth.user, auth.login(), auth.logout()
</script>
<template>
<nav>
<template v-if="auth.isAuthenticated">
<span>Welcome, {{ auth.user.name }}</span>
<button @click="auth.logout()">Logout</button>
</template>
<router-link v-else to="/login">Login</router-link>
</nav>
</template>
Pinia stores are just composables with devtools integration. You can use ref, computed, and watch inside them exactly like you would in a component. No mutations, no actions, no getters as separate concepts — just reactive state and functions.
Vue vs. React vs. Angular
This is the comparison everyone wants, so let's be honest about it.
Vue vs. React: React has a larger ecosystem, more job postings, and more community resources. Vue has better documentation, a gentler learning curve, and less boilerplate. React's JSX mixes JavaScript and markup in ways that are powerful but can be overwhelming. Vue's template syntax is more constrained but more readable. For new developers, Vue is easier to learn. For finding a job at a Silicon Valley startup, React is safer. Both are excellent choices. Vue vs. Angular: Angular is a complete platform — it includes routing, forms, HTTP client, testing utilities, and internationalization out of the box. Vue is a framework that you assemble from official and community libraries. Angular has a steeper learning curve but provides more structure for large teams. Vue gives more freedom but less guardrails. Angular uses TypeScript by default and has strong opinions about architecture. Vue supports TypeScript well but doesn't force it. The honest answer: All three frameworks can build any web application. The differences matter most for developer experience and team dynamics, not for end users. Pick the one your team will be most productive with.When to Choose Vue
Vue is the right choice when:
- You want a gentle learning curve without sacrificing capability
- Your team includes developers with varying experience levels
- You value clear documentation and a well-designed API
- You're building medium-complexity applications (dashboards, SPAs, admin panels)
- You want an official ecosystem (router, state, testing) that works together seamlessly
- You need the absolute largest third-party ecosystem (React wins here)
- Your hiring pool expects React experience (market dynamics matter)
- You're building a very large enterprise application where Angular's built-in structure prevents chaos
- You need React Native or a mature cross-platform mobile story (though Capacitor works with Vue)
Getting Started
The modern way to start a Vue project:
npm create vue@latest my-project
cd my-project
npm install
npm run dev
The create-vue scaffolding tool lets you opt into TypeScript, JSX, Router, Pinia, testing, and ESLint during setup. Start with just the defaults and add complexity as you need it — that's the Vue way.
Vue's documentation at vuejs.org is genuinely the best place to learn. It's interactive, well-structured, and maintained by the core team. The tutorial walks you through reactivity, components, and the Composition API with live code editors.
For building real proficiency with JavaScript frameworks including Vue, try the frontend challenges on CodeUp. Structured practice with real problems is worth more than reading documentation a second time.