Modern Vue.js Development: Composition API, TypeScript, and Best Practices

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

This guide provides a comprehensive foundation for modern Vue.js development using the latest patterns and best practices.