Modern Vue.js development has evolved significantly with Vue 3's Composition API, improved TypeScript support, and enhanced tooling. This guide covers contemporary Vue.js development patterns and best practices.
Vue 3 Composition API Fundamentals
Reactive State Management
<template>
<div class="user-profile">
<h2>{{ user.name }}</h2>
<p>{{ user.email }}</p>
<button @click="updateProfile" :disabled="loading">
{{ loading ? "Updating..." : "Update Profile" }}
</button>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch, onMounted } from "vue";
import type { User } from "@/types/user";
// Reactive references
const loading = ref(false);
const error = ref<string | null>(null);
// Reactive object
const user = reactive<User>({
id: 0,
name: "",
email: "",
avatar: "",
});
// Computed properties
const displayName = computed(() => user.name || "Anonymous User");
const isValid = computed(() => user.name.trim() && user.email.includes("@"));
// Watchers
watch(
() => user.email,
(newEmail) => {
if (newEmail && !newEmail.includes("@")) {
error.value = "Please enter a valid email address";
} else {
error.value = null;
}
}
);
// Methods
const updateProfile = async () => {
if (!isValid.value) return;
loading.value = true;
try {
await api.updateUser(user.id, user);
// Handle success
} catch (err) {
error.value = "Failed to update profile";
} finally {
loading.value = false;
}
};
// Lifecycle hooks
onMounted(async () => {
const userData = await api.getCurrentUser();
Object.assign(user, userData);
});
</script>
Composables (Custom Hooks)
// composables/useApi.ts
import { ref, type Ref } from "vue";
interface ApiState<T> {
data: Ref<T | null>;
loading: Ref<boolean>;
error: Ref<string | null>;
}
export function useApi<T>() {
const data = ref<T | null>(null);
const loading = ref(false);
const error = ref<string | null>(null);
const execute = async (apiCall: () => Promise<T>) => {
loading.value = true;
error.value = null;
try {
data.value = await apiCall();
} catch (err) {
error.value =
err instanceof Error ? err.message : "An error occurred";
} finally {
loading.value = false;
}
};
const reset = () => {
data.value = null;
error.value = null;
loading.value = false;
};
return {
data: readonly(data),
loading: readonly(loading),
error: readonly(error),
execute,
reset,
};
}
// composables/useUserManagement.ts
export function useUserManagement() {
const { data: users, loading, error, execute } = useApi<User[]>();
const loadUsers = () => execute(() => api.getUsers());
const createUser = async (userData: Omit<User, "id">) => {
await execute(() => api.createUser(userData));
await loadUsers(); // Refresh the list
};
const deleteUser = async (userId: number) => {
await execute(() => api.deleteUser(userId));
await loadUsers(); // Refresh the list
};
return {
users,
loading,
error,
loadUsers,
createUser,
deleteUser,
};
}
Component Communication
<!-- Parent Component -->
<template>
<div class="dashboard">
<UserList
:users="users"
@user-selected="handleUserSelection"
@user-deleted="handleUserDeletion"
/>
<UserDetails
v-if="selectedUser"
:user="selectedUser"
@user-updated="handleUserUpdate"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
import UserList from "./components/UserList.vue";
import UserDetails from "./components/UserDetails.vue";
import { useUserManagement } from "@/composables/useUserManagement";
const { users, loadUsers, deleteUser } = useUserManagement();
const selectedUser = ref<User | null>(null);
const handleUserSelection = (user: User) => {
selectedUser.value = user;
};
const handleUserDeletion = async (userId: number) => {
await deleteUser(userId);
if (selectedUser.value?.id === userId) {
selectedUser.value = null;
}
};
const handleUserUpdate = async (updatedUser: User) => {
selectedUser.value = updatedUser;
await loadUsers(); // Refresh the list
};
onMounted(() => {
loadUsers();
});
</script>
<!-- Child Component with TypeScript -->
<template>
<div class="user-list">
<div
v-for="user in users"
:key="user.id"
class="user-item"
@click="$emit('userSelected', user)"
>
<img :src="user.avatar" :alt="user.name" />
<div>
<h3>{{ user.name }}</h3>
<p>{{ user.email }}</p>
</div>
<button
@click.stop="$emit('userDeleted', user.id)"
class="delete-btn"
>
Delete
</button>
</div>
</div>
</template>
<script setup lang="ts">
interface Props {
users: User[];
}
interface Emits {
userSelected: [user: User];
userDeleted: [userId: number];
}
defineProps<Props>();
defineEmits<Emits>();
</script>
State Management with Pinia
Store Definition
// stores/userStore.ts
import { defineStore } from "pinia";
import { ref, computed } from "vue";
import type { User, CreateUserRequest } from "@/types/user";
export const useUserStore = defineStore("user", () => {
// State
const users = ref<User[]>([]);
const currentUser = ref<User | null>(null);
const loading = ref(false);
const error = ref<string | null>(null);
// Getters
const activeUsers = computed(() =>
users.value.filter((user) => user.isActive)
);
const totalUsers = computed(() => users.value.length);
const isLoggedIn = computed(() => currentUser.value !== null);
// Actions
const fetchUsers = async () => {
loading.value = true;
error.value = null;
try {
const response = await api.getUsers();
users.value = response.data;
} catch (err) {
error.value = "Failed to fetch users";
console.error(err);
} finally {
loading.value = false;
}
};
const createUser = async (userData: CreateUserRequest) => {
try {
const response = await api.createUser(userData);
users.value.push(response.data);
return response.data;
} catch (err) {
error.value = "Failed to create user";
throw err;
}
};
const updateUser = async (userId: number, userData: Partial<User>) => {
try {
const response = await api.updateUser(userId, userData);
const index = users.value.findIndex((user) => user.id === userId);
if (index !== -1) {
users.value[index] = response.data;
}
return response.data;
} catch (err) {
error.value = "Failed to update user";
throw err;
}
};
const deleteUser = async (userId: number) => {
try {
await api.deleteUser(userId);
users.value = users.value.filter((user) => user.id !== userId);
} catch (err) {
error.value = "Failed to delete user";
throw err;
}
};
const login = async (credentials: LoginCredentials) => {
try {
const response = await api.login(credentials);
currentUser.value = response.user;
localStorage.setItem("token", response.token);
return response;
} catch (err) {
error.value = "Login failed";
throw err;
}
};
const logout = () => {
currentUser.value = null;
localStorage.removeItem("token");
};
const clearError = () => {
error.value = null;
};
return {
// State
users,
currentUser,
loading,
error,
// Getters
activeUsers,
totalUsers,
isLoggedIn,
// Actions
fetchUsers,
createUser,
updateUser,
deleteUser,
login,
logout,
clearError,
};
});
Store Usage in Components
<template>
<div class="user-management">
<div v-if="loading" class="loading">Loading users...</div>
<div v-else-if="error" class="error">
{{ error }}
<button @click="clearError">Dismiss</button>
</div>
<div v-else class="user-grid">
<UserCard
v-for="user in activeUsers"
:key="user.id"
:user="user"
@edit="editUser"
@delete="deleteUser"
/>
</div>
<div class="stats">
<p>Total Users: {{ totalUsers }}</p>
<p>Active Users: {{ activeUsers.length }}</p>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted } from "vue";
import { useUserStore } from "@/stores/userStore";
import UserCard from "./components/UserCard.vue";
const userStore = useUserStore();
// Destructure reactive properties
const { activeUsers, totalUsers, loading, error } = storeToRefs(userStore);
// Use actions directly
const { fetchUsers, deleteUser: deleteUserAction, clearError } = userStore;
const editUser = (user: User) => {
// Navigate to edit page or open modal
router.push(`/users/${user.id}/edit`);
};
const deleteUser = async (userId: number) => {
if (confirm("Are you sure you want to delete this user?")) {
await deleteUserAction(userId);
}
};
onMounted(() => {
fetchUsers();
});
</script>
Form Handling and Validation
Modern Form Patterns
<template>
<form @submit.prevent="handleSubmit" class="user-form">
<div class="form-group">
<label for="name">Name</label>
<input
id="name"
v-model="form.name"
type="text"
:class="{ error: errors.name }"
@blur="validateField('name')"
/>
<span v-if="errors.name" class="error-message">
{{ errors.name }}
</span>
</div>
<div class="form-group">
<label for="email">Email</label>
<input
id="email"
v-model="form.email"
type="email"
:class="{ error: errors.email }"
@blur="validateField('email')"
/>
<span v-if="errors.email" class="error-message">
{{ errors.email }}
</span>
</div>
<div class="form-group">
<label for="role">Role</label>
<select
id="role"
v-model="form.role"
:class="{ error: errors.role }"
>
<option value="">Select a role</option>
<option
v-for="role in availableRoles"
:key="role"
:value="role"
>
{{ role }}
</option>
</select>
<span v-if="errors.role" class="error-message">
{{ errors.role }}
</span>
</div>
<div class="form-actions">
<button type="button" @click="resetForm">Reset</button>
<button
type="submit"
:disabled="!isFormValid || submitting"
class="primary"
>
{{ submitting ? "Saving..." : "Save User" }}
</button>
</div>
</form>
</template>
<script setup lang="ts">
import { reactive, computed, ref } from "vue";
import { useFormValidation } from "@/composables/useFormValidation";
interface UserForm {
name: string;
email: string;
role: string;
}
const availableRoles = ["Admin", "User", "Moderator"];
const submitting = ref(false);
const form = reactive<UserForm>({
name: "",
email: "",
role: "",
});
const validationRules = {
name: {
required: true,
minLength: 2,
},
email: {
required: true,
email: true,
},
role: {
required: true,
},
};
const { errors, validateField, validateForm, isFormValid, resetErrors } =
useFormValidation(form, validationRules);
const handleSubmit = async () => {
if (!validateForm()) return;
submitting.value = true;
try {
await userStore.createUser(form);
resetForm();
// Show success message or redirect
} catch (error) {
// Handle submission error
} finally {
submitting.value = false;
}
};
const resetForm = () => {
Object.assign(form, {
name: "",
email: "",
role: "",
});
resetErrors();
};
</script>
Form Validation Composable
// composables/useFormValidation.ts
import { ref, reactive, computed } from "vue";
interface ValidationRule {
required?: boolean;
minLength?: number;
maxLength?: number;
email?: boolean;
pattern?: RegExp;
custom?: (value: any) => string | null;
}
interface ValidationRules {
[key: string]: ValidationRule;
}
export function useFormValidation<T extends Record<string, any>>(
form: T,
rules: ValidationRules
) {
const errors = reactive<Record<keyof T, string | null>>({} as any);
const validateField = (field: keyof T): boolean => {
const value = form[field];
const rule = rules[field as string];
if (!rule) {
errors[field] = null;
return true;
}
// Required validation
if (rule.required && (!value || value.toString().trim() === "")) {
errors[field] = `${String(field)} is required`;
return false;
}
// Skip other validations if field is empty and not required
if (!value && !rule.required) {
errors[field] = null;
return true;
}
// Minimum length validation
if (rule.minLength && value.toString().length < rule.minLength) {
errors[field] = `${String(field)} must be at least ${
rule.minLength
} characters`;
return false;
}
// Maximum length validation
if (rule.maxLength && value.toString().length > rule.maxLength) {
errors[field] = `${String(field)} must be no more than ${
rule.maxLength
} characters`;
return false;
}
// Email validation
if (rule.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
errors[field] = `${String(field)} must be a valid email address`;
return false;
}
// Pattern validation
if (rule.pattern && !rule.pattern.test(value)) {
errors[field] = `${String(field)} format is invalid`;
return false;
}
// Custom validation
if (rule.custom) {
const customError = rule.custom(value);
if (customError) {
errors[field] = customError;
return false;
}
}
errors[field] = null;
return true;
};
const validateForm = (): boolean => {
const fields = Object.keys(rules) as (keyof T)[];
return fields.every((field) => validateField(field));
};
const isFormValid = computed(() => {
return (
Object.values(errors).every((error) => !error) &&
Object.keys(rules).every((field) => form[field] !== undefined)
);
});
const resetErrors = () => {
Object.keys(errors).forEach((key) => {
errors[key as keyof T] = null;
});
};
return {
errors,
validateField,
validateForm,
isFormValid,
resetErrors,
};
}
Testing Vue Components
Component Testing with Vitest
// tests/components/UserCard.test.ts
import { describe, it, expect, vi } from "vitest";
import { mount } from "@vue/test-utils";
import UserCard from "@/components/UserCard.vue";
import type { User } from "@/types/user";
const mockUser: User = {
id: 1,
name: "John Doe",
email: "[email protected]",
avatar: "https://example.com/avatar.jpg",
isActive: true,
};
describe("UserCard", () => {
it("renders user information correctly", () => {
const wrapper = mount(UserCard, {
props: { user: mockUser },
});
expect(wrapper.text()).toContain("John Doe");
expect(wrapper.text()).toContain("[email protected]");
expect(wrapper.find("img").attributes("src")).toBe(mockUser.avatar);
});
it("emits edit event when edit button is clicked", async () => {
const wrapper = mount(UserCard, {
props: { user: mockUser },
});
await wrapper.find(".edit-btn").trigger("click");
expect(wrapper.emitted("edit")).toBeTruthy();
expect(wrapper.emitted("edit")?.[0]).toEqual([mockUser]);
});
it("emits delete event when delete button is clicked", async () => {
const wrapper = mount(UserCard, {
props: { user: mockUser },
});
await wrapper.find(".delete-btn").trigger("click");
expect(wrapper.emitted("delete")).toBeTruthy();
expect(wrapper.emitted("delete")?.[0]).toEqual([mockUser.id]);
});
it("applies correct CSS classes based on user status", () => {
const activeWrapper = mount(UserCard, {
props: { user: mockUser },
});
expect(activeWrapper.classes()).toContain("active");
const inactiveUser = { ...mockUser, isActive: false };
const inactiveWrapper = mount(UserCard, {
props: { user: inactiveUser },
});
expect(inactiveWrapper.classes()).toContain("inactive");
});
});
Store Testing
// tests/stores/userStore.test.ts
import { describe, it, expect, beforeEach, vi } from "vitest";
import { setActivePinia, createPinia } from "pinia";
import { useUserStore } from "@/stores/userStore";
import * as api from "@/services/api";
vi.mock("@/services/api");
const mockedApi = vi.mocked(api);
describe("User Store", () => {
beforeEach(() => {
setActivePinia(createPinia());
vi.clearAllMocks();
});
it("fetches users successfully", async () => {
const mockUsers = [
{ id: 1, name: "John", email: "[email protected]", isActive: true },
{ id: 2, name: "Jane", email: "[email protected]", isActive: false },
];
mockedApi.getUsers.mockResolvedValue({ data: mockUsers });
const store = useUserStore();
await store.fetchUsers();
expect(store.users).toEqual(mockUsers);
expect(store.loading).toBe(false);
expect(store.error).toBe(null);
});
it("handles API errors during user fetch", async () => {
mockedApi.getUsers.mockRejectedValue(new Error("Network error"));
const store = useUserStore();
await store.fetchUsers();
expect(store.users).toEqual([]);
expect(store.loading).toBe(false);
expect(store.error).toBe("Failed to fetch users");
});
it("computes active users correctly", () => {
const store = useUserStore();
store.users = [
{ id: 1, name: "John", email: "[email protected]", isActive: true },
{ id: 2, name: "Jane", email: "[email protected]", isActive: false },
{ id: 3, name: "Bob", email: "[email protected]", isActive: true },
];
expect(store.activeUsers).toHaveLength(2);
expect(store.activeUsers.every((user) => user.isActive)).toBe(true);
});
});
Performance Optimization
Component Optimization
<template>
<div class="user-list">
<!-- Use v-memo for expensive list items -->
<UserCard
v-for="user in users"
:key="user.id"
v-memo="[user.id, user.name, user.isActive]"
:user="user"
@edit="editUser"
@delete="deleteUser"
/>
</div>
</template>
<script setup lang="ts">
import { defineAsyncComponent } from "vue";
// Lazy load heavy components
const UserModal = defineAsyncComponent(() => import("./UserModal.vue"));
const UserChart = defineAsyncComponent({
loader: () => import("./UserChart.vue"),
loadingComponent: LoadingSpinner,
errorComponent: ErrorComponent,
delay: 200,
timeout: 3000,
});
// Use shallowRef for large objects that don't need deep reactivity
const largeDataset = shallowRef([]);
// Use readonly for computed data
const sortedUsers = computed(() =>
readonly(users.value.sort((a, b) => a.name.localeCompare(b.name)))
);
</script>
Virtual Scrolling for Large Lists
<template>
<div class="virtual-list" ref="containerRef" @scroll="handleScroll">
<div :style="{ height: totalHeight + 'px' }" class="scroll-area">
<div
:style="{ transform: `translateY(${startOffset}px)` }"
class="visible-items"
>
<div
v-for="item in visibleItems"
:key="item.id"
:style="{ height: itemHeight + 'px' }"
class="list-item"
>
<slot :item="item" />
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, nextTick } from "vue";
interface Props {
items: any[];
itemHeight: number;
containerHeight: number;
}
const props = defineProps<Props>();
const containerRef = ref<HTMLElement>();
const scrollTop = ref(0);
const visibleCount = computed(
() => Math.ceil(props.containerHeight / props.itemHeight) + 2
);
const startIndex = computed(() =>
Math.floor(scrollTop.value / props.itemHeight)
);
const endIndex = computed(() =>
Math.min(startIndex.value + visibleCount.value, props.items.length)
);
const visibleItems = computed(() =>
props.items.slice(startIndex.value, endIndex.value)
);
const totalHeight = computed(() => props.items.length * props.itemHeight);
const startOffset = computed(() => startIndex.value * props.itemHeight);
const handleScroll = () => {
if (containerRef.value) {
scrollTop.value = containerRef.value.scrollTop;
}
};
</script>
Build and Deployment
Modern Build Configuration
// vite.config.ts
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import { resolve } from "path";
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
"@": resolve(__dirname, "src"),
},
},
build: {
rollupOptions: {
output: {
manualChunks: {
vendor: ["vue", "vue-router", "pinia"],
ui: ["@/components/ui"],
},
},
},
chunkSizeWarningLimit: 1000,
},
server: {
proxy: {
"/api": {
target: "http://localhost:8000",
changeOrigin: true,
},
},
},
});
Best Practices Summary
Code Organization
- Use Composition API for better code reuse
- Create focused, single-responsibility composables
- Implement proper TypeScript types
- Use Pinia for complex state management
Performance
- Use
v-memo
for expensive list items - Implement virtual scrolling for large datasets
- Lazy load heavy components
- Use
shallowRef
for large objects
Testing
- Write component tests for user interactions
- Test store actions and getters
- Mock external dependencies
- Use meaningful test descriptions
Related Resources
- Use Vue in Laravel Application - Backend integration
- Modern JavaScript Development Guide - JavaScript fundamentals
- TypeScript Type vs Interface: When to Use Each - TypeScript best practices
This guide provides a comprehensive foundation for modern Vue.js development using the latest patterns and best practices.