⏱️4 min read · 858 words
Vue 3 with the Composition API, Pinia state management, and Nuxt.js for full-stack — Vue is the second most popular JavaScript framework in 2026 with an excellent developer experience. This complete guide covers Vue 3’s Composition API, reactivity system, and production patterns.
📋 Table of Contents
Setup
# Create new project
npm create vue@latest my-app
# Choose: TypeScript, Vue Router, Pinia, ESLint, Prettier
cd my-app && npm install && npm run dev
Composition API
<!-- Counter.vue — script setup syntax (preferred) -->
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue';
// ref — reactive primitive
const count = ref(0);
const name = ref('World');
// computed — derived state (cached)
const doubled = computed(() => count.value * 2);
const greeting = computed(() => `Hello, ${name.value}! Count: ${count.value}`);
// watch — side effects
watch(count, (newVal, oldVal) => {
console.log(`Count changed: ${oldVal} → ${newVal}`);
document.title = `Count: ${newVal}`;
});
// watchEffect — auto-tracks dependencies
watchEffect(() => {
localStorage.setItem('count', count.value.toString());
});
// Lifecycle hooks
onMounted(() => {
console.log('Component mounted!');
count.value = parseInt(localStorage.getItem('count') || '0');
});
function increment() {
count.value++;
}
function reset() {
count.value = 0;
}
</script>
<template>
<div class="counter">
<h2>{{ greeting }}</h2>
<p>Doubled: {{ doubled }}</p>
<!-- v-model works with refs automatically -->
<input v-model="name" placeholder="Your name" />
<div>
<button @click="increment">+1</button>
<button @click="reset">Reset</button>
</div>
</div>
</template>
Reactive Objects with reactive()
<script setup lang="ts">
import { reactive, toRefs } from 'vue';
// reactive — for objects (no .value needed)
const state = reactive({
count: 0,
name: 'Alice',
todos: [] as string[],
});
// Destructure with toRefs (maintains reactivity)
const { count, name } = toRefs(state);
// Mutate directly
function addTodo(text: string) {
state.todos.push(text);
}
</script>
Components and Props
<!-- UserCard.vue -->
<script setup lang="ts">
interface Props {
user: {
id: number;
name: string;
email: string;
avatarUrl?: string;
};
isSelected?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
isSelected: false,
});
const emit = defineEmits<{
select: [userId: number];
delete: [userId: number];
}>();
function handleSelect() {
emit('select', props.user.id);
}
</script>
<template>
<div
class="user-card"
:class="{ 'user-card--selected': isSelected }"
@click="handleSelect"
>
<img
v-if="user.avatarUrl"
:src="user.avatarUrl"
:alt="user.name"
/>
<div>
<h3>{{ user.name }}</h3>
<p>{{ user.email }}</p>
</div>
<button @click.stop="emit('delete', user.id)">Delete</button>
</div>
</template>
Composables — Custom Hooks
// composables/useUsers.ts
import { ref, computed } from 'vue';
interface User { id: number; name: string; email: string; }
export function useUsers() {
const users = ref<User[]>([]);
const loading = ref(false);
const error = ref<string | null>(null);
const searchQuery = ref('');
const filteredUsers = computed(() =>
users.value.filter(u =>
u.name.toLowerCase().includes(searchQuery.value.toLowerCase())
)
);
async function fetchUsers() {
loading.value = true;
error.value = null;
try {
const response = await fetch('/api/users');
users.value = await response.json();
} catch (e) {
error.value = 'Failed to load users';
} finally {
loading.value = false;
}
}
async function createUser(data: Omit<User, 'id'>) {
const response = await fetch('/api/users', {
method: 'POST',
body: JSON.stringify(data),
headers: { 'Content-Type': 'application/json' },
});
const newUser = await response.json();
users.value = [...users.value, newUser];
}
return { users, loading, error, searchQuery, filteredUsers, fetchUsers, createUser };
}
// Usage in component
const { users, loading, error, searchQuery, filteredUsers, fetchUsers } = useUsers();
onMounted(fetchUsers);
Pinia State Management
// stores/userStore.ts
import { defineStore } from 'pinia';
export const useUserStore = defineStore('users', {
state: () => ({
users: [] as User[],
currentUser: null as User | null,
loading: false,
}),
getters: {
activeUsers: (state) => state.users.filter(u => u.active),
userCount: (state) => state.users.length,
},
actions: {
async fetchUsers() {
this.loading = true;
try {
this.users = await api.getUsers();
} finally {
this.loading = false;
}
},
async createUser(data: CreateUserInput) {
const user = await api.createUser(data);
this.users.push(user);
return user;
},
logout() {
this.currentUser = null;
}
}
});
// In component
import { useUserStore } from '@/stores/userStore';
const store = useUserStore();
await store.fetchUsers();
console.log(store.userCount);
store.logout();
Vue vs React: When to Choose Vue
- Choose Vue when: Simpler learning curve needed, template-first approach preferred, smaller team, gradual migration from jQuery/Vanilla JS
- Choose React when: Large team, complex state management needed, Next.js ecosystem, React Native for mobile
- Both are excellent for most web apps — the choice often comes down to team familiarity
Vue 3 in 2026 with script setup, Composition API, and Pinia is a mature, developer-friendly framework. The template syntax is more approachable than JSX for many developers, and the Composition API gives full power for complex components. For full-stack, pair with Nuxt.js for the same productivity gains as Next.js for React.
📚 You might also like
🔗 Share this article




✍️ Leave a Comment