Building a Vue.js Application
Vue.js Development
Building a Vue.js Application
Building a complete Vue.js application brings together all the concepts covered in this learning path: components, reactivity, routing, state management, API integration, and forms. In this final step, we will walk through the architecture and key implementation patterns of a real-world task management application. This application demonstrates how to structure a Vue project for maintainability, how to connect the front end to a REST API, and how to apply best practices that scale from small projects to large team codebases.
Project Architecture
A well-organized Vue project separates concerns into clear directories. Each directory has a specific purpose, making the codebase navigable for both current and future developers.
src/
api/ # API client and service modules
client.js # Axios instance with interceptors
taskService.js # Task-specific API calls
authService.js # Auth API calls
assets/ # Static assets (images, fonts)
components/ # Reusable UI components
AppHeader.vue
TaskCard.vue
TaskForm.vue
LoadingSpinner.vue
ErrorAlert.vue
composables/ # Shared composition functions
useAuth.js
useApi.js
useNotification.js
layouts/ # Page layout wrappers
DefaultLayout.vue
AuthLayout.vue
router/ # Vue Router configuration
index.js
guards.js
stores/ # Pinia stores
auth.js
tasks.js
views/ # Page-level components
HomeView.vue
LoginView.vue
TaskListView.vue
TaskDetailView.vue
App.vue # Root component
main.js # Application entry point
Application Entry Point
// main.js
import { createApp } from "vue";
import { createPinia } from "pinia";
import App from "./App.vue";
import router from "./router";
import "./assets/main.css";
const app = createApp(App);
app.use(createPinia());
app.use(router);
app.mount("#app");
Task Store with Pinia
// stores/tasks.js
import { defineStore } from "pinia";
import taskService from "../api/taskService";
export const useTaskStore = defineStore("tasks", {
state: () => ({
tasks: [],
currentTask: null,
filter: "all", // "all" | "active" | "completed"
loading: false,
error: null,
}),
getters: {
filteredTasks(state) {
switch (state.filter) {
case "active": return state.tasks.filter((t) => !t.completed);
case "completed": return state.tasks.filter((t) => t.completed);
default: return state.tasks;
}
},
taskCount: (state) => state.tasks.length,
completedCount: (state) => state.tasks.filter((t) => t.completed).length,
},
actions: {
async fetchTasks() {
this.loading = true;
this.error = null;
try {
this.tasks = await taskService.getAll();
} catch (e) {
this.error = e.message;
} finally {
this.loading = false;
}
},
async addTask(title, description) {
const newTask = await taskService.create({ title, description });
this.tasks.unshift(newTask);
},
async toggleTask(id) {
const task = this.tasks.find((t) => t.id === id);
if (task) {
const updated = await taskService.update(id, { completed: !task.completed });
Object.assign(task, updated);
}
},
async removeTask(id) {
await taskService.remove(id);
this.tasks = this.tasks.filter((t) => t.id !== id);
},
},
});
Task List View
<!-- views/TaskListView.vue -->
<template>
<div class="task-list-view">
<header>
<h1>Tasks ({{ store.completedCount }}/{{ store.taskCount }})</h1>
<div class="filters">
<button
v-for="f in ['all', 'active', 'completed']"
:key="f"
:class="{ active: store.filter === f }"
@click="store.filter = f"
>
{{ f }}
</button>
</div>
</header>
<TaskForm @submit="handleAddTask" />
<LoadingSpinner v-if="store.loading" />
<ErrorAlert v-else-if="store.error" :message="store.error" @retry="store.fetchTasks()" />
<TransitionGroup v-else name="list" tag="div" class="tasks">
<TaskCard
v-for="task in store.filteredTasks"
:key="task.id"
:task="task"
@toggle="store.toggleTask(task.id)"
@delete="store.removeTask(task.id)"
/>
</TransitionGroup>
</div>
</template>
<script setup>
import { onMounted } from "vue";
import { useTaskStore } from "../stores/tasks";
import TaskCard from "../components/TaskCard.vue";
import TaskForm from "../components/TaskForm.vue";
import LoadingSpinner from "../components/LoadingSpinner.vue";
import ErrorAlert from "../components/ErrorAlert.vue";
const store = useTaskStore();
onMounted(() => {
store.fetchTasks();
});
async function handleAddTask({ title, description }) {
await store.addTask(title, description);
}
</script>
Best Practices Summary
// 1. Keep components small and focused (under 200 lines)
// 2. Use Composition API + script setup for new components
// 3. Extract reusable logic into composables
// 4. Centralize API calls in service modules
// 5. Use Pinia stores for shared state, local state for component-only data
// 6. Add loading and error states for every async operation
// 7. Use TypeScript for type safety in large projects
// 8. Lazy-load route components for better performance
// 9. Use scoped styles to prevent CSS conflicts
// 10. Write meaningful component names (PascalCase, multi-word)
Tip: Start new projects with create-vue, select TypeScript and Pinia from the start, and organize code by feature domain rather than by file type. As the project grows, this structure scales naturally without major refactoring.
Key Takeaways
- Structure projects with clear separation: api, components, composables, stores, views, and router.
- Use Pinia stores to manage shared state and centralize API interactions.
- Compose views from small, focused components connected via props and events.
- Always handle loading, error, and empty states for async operations.
- Follow naming conventions, extract reusable logic, and prefer the Composition API for new code.