Vue.js Essentials - From Setup to Todo App
Complete Vue.js 3 guide from project setup to building a functional Todo application. Includes Composition API, TypeScript, and best practices.
Docker Setup
Dockerfile
1# Build stage
2FROM node:18-alpine AS builder
3WORKDIR /app
4COPY package*.json ./
5RUN npm ci
6COPY . .
7RUN npm run build
8
9# Production stage
10FROM nginx:alpine
11COPY --from=builder /app/dist /usr/share/nginx/html
12EXPOSE 80
13CMD ["nginx", "-g", "daemon off;"]
Docker Compose
1version: '3.8'
2
3services:
4 vue-app:
5 build: .
6 container_name: vue-app
7 ports:
8 - "3000:80"
9 restart: unless-stopped
10
11 # Development
12 vue-dev:
13 image: node:18-alpine
14 container_name: vue-dev
15 working_dir: /app
16 volumes:
17 - ./:/app
18 - /app/node_modules
19 ports:
20 - "5173:5173"
21 command: npm run dev -- --host
22 restart: unless-stopped
Project Setup
Vite (Recommended)
1# Create new Vue app
2npm create vue@latest my-app
3
4# Follow prompts:
5# ✔ Add TypeScript? › Yes
6# ✔ Add JSX Support? › No
7# ✔ Add Vue Router? › Yes
8# ✔ Add Pinia (state management)? › Yes
9# ✔ Add Vitest (unit testing)? › No
10# ✔ Add ESLint? › Yes
11
12cd my-app
13npm install
14npm run dev
Node.js Version Requirements:
- Vue 3.3+: Node.js 18+ or 20+
- Vue 3.2: Node.js 14.18+ or 16+
Check versions:
1node --version
2npm --version
3vue --version
Vue CLI (Legacy)
1npm install -g @vue/cli
2vue create my-app
3cd my-app
4npm run serve
⚠️ Gotcha: Vue CLI is in maintenance mode. Use Vite instead.
Basic Vue Concepts
Single File Components (SFC)
1<!-- HelloWorld.vue -->
2<template>
3 <div class="hello">
4 <h1>{{ msg }}</h1>
5 <button @click="count++">Count: {{ count }}</button>
6 </div>
7</template>
8
9<script setup lang="ts">
10import { ref } from 'vue';
11
12// Props
13defineProps<{
14 msg: string;
15}>();
16
17// Reactive state
18const count = ref(0);
19</script>
20
21<style scoped>
22.hello {
23 color: #42b983;
24}
25</style>
Composition API vs Options API
Options API (Vue 2 style)
1<script lang="ts">
2export default {
3 data() {
4 return {
5 count: 0,
6 message: 'Hello'
7 };
8 },
9 computed: {
10 doubleCount() {
11 return this.count * 2;
12 }
13 },
14 methods: {
15 increment() {
16 this.count++;
17 }
18 },
19 mounted() {
20 console.log('Component mounted');
21 }
22};
23</script>
Composition API (Vue 3 - Recommended)
1<script setup lang="ts">
2import { ref, computed, onMounted } from 'vue';
3
4const count = ref(0);
5const message = ref('Hello');
6
7const doubleCount = computed(() => count.value * 2);
8
9const increment = () => {
10 count.value++;
11};
12
13onMounted(() => {
14 console.log('Component mounted');
15});
16</script>
⚠️ Gotcha: In Composition API, always use .value to access/modify refs!
1// ❌ Wrong
2count = 5;
3
4// ✅ Correct
5count.value = 5;
Reactivity
ref vs reactive
1<script setup lang="ts">
2import { ref, reactive } from 'vue';
3
4// ref - for primitives (unwraps in template)
5const count = ref(0);
6const message = ref('Hello');
7
8// reactive - for objects
9const state = reactive({
10 count: 0,
11 message: 'Hello'
12});
13
14// In script: use .value for ref
15count.value++;
16
17// In script: direct access for reactive
18state.count++;
19</script>
20
21<template>
22 <!-- In template: no .value needed for ref -->
23 <p>{{ count }}</p>
24 <p>{{ state.count }}</p>
25</template>
⚠️ Gotcha: Don't destructure reactive objects!
1// ❌ Wrong - loses reactivity
2const { count, message } = reactive({ count: 0, message: 'Hello' });
3
4// ✅ Correct - use toRefs
5import { toRefs } from 'vue';
6const state = reactive({ count: 0, message: 'Hello' });
7const { count, message } = toRefs(state);
Computed Properties
1<script setup lang="ts">
2import { ref, computed } from 'vue';
3
4const firstName = ref('John');
5const lastName = ref('Doe');
6
7// Read-only computed
8const fullName = computed(() => {
9 return `${firstName.value} ${lastName.value}`;
10});
11
12// Writable computed
13const fullNameWritable = computed({
14 get() {
15 return `${firstName.value} ${lastName.value}`;
16 },
17 set(value: string) {
18 const parts = value.split(' ');
19 firstName.value = parts[0];
20 lastName.value = parts[1];
21 }
22});
23</script>
Watchers
1<script setup lang="ts">
2import { ref, watch, watchEffect } from 'vue';
3
4const count = ref(0);
5const message = ref('');
6
7// Watch single source
8watch(count, (newValue, oldValue) => {
9 console.log(`Count changed from ${oldValue} to ${newValue}`);
10});
11
12// Watch multiple sources
13watch([count, message], ([newCount, newMessage], [oldCount, oldMessage]) => {
14 console.log('Something changed');
15});
16
17// Watch with options
18watch(count, (newValue) => {
19 console.log('Count:', newValue);
20}, {
21 immediate: true, // Run immediately
22 deep: true // Deep watch for objects
23});
24
25// watchEffect - automatically tracks dependencies
26watchEffect(() => {
27 console.log(`Count is ${count.value}`);
28 // Automatically re-runs when count changes
29});
30</script>
Lifecycle Hooks
1<script setup lang="ts">
2import {
3 onBeforeMount,
4 onMounted,
5 onBeforeUpdate,
6 onUpdated,
7 onBeforeUnmount,
8 onUnmounted
9} from 'vue';
10
11onBeforeMount(() => {
12 console.log('Before mount');
13});
14
15onMounted(() => {
16 console.log('Mounted - DOM is ready');
17 // Fetch data, setup timers, etc.
18});
19
20onBeforeUpdate(() => {
21 console.log('Before update');
22});
23
24onUpdated(() => {
25 console.log('Updated');
26});
27
28onBeforeUnmount(() => {
29 console.log('Before unmount');
30 // Cleanup: remove event listeners, cancel timers
31});
32
33onUnmounted(() => {
34 console.log('Unmounted');
35});
36</script>
Complete Todo App Example
1<!-- App.vue -->
2<template>
3 <div class="app">
4 <h1>Vue Todo App</h1>
5
6 <!-- Input -->
7 <div class="input-container">
8 <input
9 v-model="newTodo"
10 @keyup.enter="addTodo"
11 placeholder="What needs to be done?"
12 />
13 <button @click="addTodo">Add</button>
14 </div>
15
16 <!-- Filter buttons -->
17 <div class="filters">
18 <button
19 :class="{ active: filter === 'all' }"
20 @click="filter = 'all'"
21 >
22 All ({{ todos.length }})
23 </button>
24 <button
25 :class="{ active: filter === 'active' }"
26 @click="filter = 'active'"
27 >
28 Active ({{ activeCount }})
29 </button>
30 <button
31 :class="{ active: filter === 'completed' }"
32 @click="filter = 'completed'"
33 >
34 Completed ({{ completedCount }})
35 </button>
36 </div>
37
38 <!-- Todo list -->
39 <ul class="todo-list">
40 <TodoItem
41 v-for="todo in filteredTodos"
42 :key="todo.id"
43 :todo="todo"
44 @toggle="toggleTodo"
45 @delete="deleteTodo"
46 @edit="editTodo"
47 />
48 </ul>
49
50 <!-- Footer -->
51 <div v-if="todos.length > 0" class="footer">
52 <span>{{ activeCount }} item{{ activeCount !== 1 ? 's' : '' }} left</span>
53 <button @click="clearCompleted">Clear completed</button>
54 </div>
55 </div>
56</template>
57
58<script setup lang="ts">
59import { ref, computed } from 'vue';
60import TodoItem from './components/TodoItem.vue';
61
62interface Todo {
63 id: number;
64 text: string;
65 completed: boolean;
66}
67
68type Filter = 'all' | 'active' | 'completed';
69
70// State
71const todos = ref<Todo[]>([]);
72const newTodo = ref('');
73const filter = ref<Filter>('all');
74
75// Computed
76const filteredTodos = computed(() => {
77 switch (filter.value) {
78 case 'active':
79 return todos.value.filter(todo => !todo.completed);
80 case 'completed':
81 return todos.value.filter(todo => todo.completed);
82 default:
83 return todos.value;
84 }
85});
86
87const activeCount = computed(() => {
88 return todos.value.filter(todo => !todo.completed).length;
89});
90
91const completedCount = computed(() => {
92 return todos.value.filter(todo => todo.completed).length;
93});
94
95// Methods
96const addTodo = () => {
97 if (newTodo.value.trim() === '') return;
98
99 todos.value.push({
100 id: Date.now(),
101 text: newTodo.value,
102 completed: false
103 });
104
105 newTodo.value = '';
106};
107
108const toggleTodo = (id: number) => {
109 const todo = todos.value.find(t => t.id === id);
110 if (todo) {
111 todo.completed = !todo.completed;
112 }
113};
114
115const deleteTodo = (id: number) => {
116 todos.value = todos.value.filter(t => t.id !== id);
117};
118
119const editTodo = (id: number, newText: string) => {
120 const todo = todos.value.find(t => t.id === id);
121 if (todo) {
122 todo.text = newText;
123 }
124};
125
126const clearCompleted = () => {
127 todos.value = todos.value.filter(t => !t.completed);
128};
129</script>
130
131<style scoped>
132.app {
133 max-width: 600px;
134 margin: 50px auto;
135 padding: 20px;
136 font-family: Arial, sans-serif;
137}
138
139h1 {
140 text-align: center;
141 color: #333;
142}
143
144.input-container {
145 display: flex;
146 gap: 10px;
147 margin-bottom: 20px;
148}
149
150.input-container input {
151 flex: 1;
152 padding: 10px;
153 font-size: 16px;
154 border: 1px solid #ddd;
155 border-radius: 4px;
156}
157
158.input-container button {
159 padding: 10px 20px;
160 font-size: 16px;
161 background: #42b983;
162 color: white;
163 border: none;
164 border-radius: 4px;
165 cursor: pointer;
166}
167
168.input-container button:hover {
169 background: #3aa876;
170}
171
172.filters {
173 display: flex;
174 gap: 10px;
175 margin-bottom: 20px;
176}
177
178.filters button {
179 flex: 1;
180 padding: 8px;
181 background: #f0f0f0;
182 border: 1px solid #ddd;
183 border-radius: 4px;
184 cursor: pointer;
185}
186
187.filters button.active {
188 background: #42b983;
189 color: white;
190}
191
192.todo-list {
193 list-style: none;
194 padding: 0;
195}
196
197.footer {
198 display: flex;
199 justify-content: space-between;
200 align-items: center;
201 margin-top: 20px;
202 padding-top: 20px;
203 border-top: 1px solid #ddd;
204}
205
206.footer button {
207 padding: 8px 16px;
208 background: #f44336;
209 color: white;
210 border: none;
211 border-radius: 4px;
212 cursor: pointer;
213}
214
215.footer button:hover {
216 background: #da190b;
217}
218</style>
TodoItem Component
1<!-- components/TodoItem.vue -->
2<template>
3 <li :class="['todo-item', { completed: todo.completed, editing: isEditing }]">
4 <template v-if="!isEditing">
5 <input
6 type="checkbox"
7 :checked="todo.completed"
8 @change="$emit('toggle', todo.id)"
9 />
10 <span @dblclick="startEdit">{{ todo.text }}</span>
11 <button @click="$emit('delete', todo.id)">×</button>
12 </template>
13
14 <template v-else>
15 <input
16 ref="editInput"
17 v-model="editText"
18 type="text"
19 @blur="finishEdit"
20 @keyup.enter="finishEdit"
21 @keyup.esc="cancelEdit"
22 />
23 </template>
24 </li>
25</template>
26
27<script setup lang="ts">
28import { ref, nextTick } from 'vue';
29
30interface Todo {
31 id: number;
32 text: string;
33 completed: boolean;
34}
35
36const props = defineProps<{
37 todo: Todo;
38}>();
39
40const emit = defineEmits<{
41 toggle: [id: number];
42 delete: [id: number];
43 edit: [id: number, text: string];
44}>();
45
46const isEditing = ref(false);
47const editText = ref('');
48const editInput = ref<HTMLInputElement | null>(null);
49
50const startEdit = () => {
51 isEditing.value = true;
52 editText.value = props.todo.text;
53
54 // Focus input after DOM update
55 nextTick(() => {
56 editInput.value?.focus();
57 });
58};
59
60const finishEdit = () => {
61 if (editText.value.trim() === '') {
62 emit('delete', props.todo.id);
63 } else {
64 emit('edit', props.todo.id, editText.value);
65 }
66 isEditing.value = false;
67};
68
69const cancelEdit = () => {
70 isEditing.value = false;
71 editText.value = props.todo.text;
72};
73</script>
74
75<style scoped>
76.todo-item {
77 display: flex;
78 align-items: center;
79 gap: 10px;
80 padding: 10px;
81 border: 1px solid #ddd;
82 border-radius: 4px;
83 margin-bottom: 10px;
84}
85
86.todo-item.completed span {
87 text-decoration: line-through;
88 color: #999;
89}
90
91.todo-item input[type="checkbox"] {
92 width: 20px;
93 height: 20px;
94 cursor: pointer;
95}
96
97.todo-item span {
98 flex: 1;
99 cursor: pointer;
100}
101
102.todo-item button {
103 width: 30px;
104 height: 30px;
105 background: #f44336;
106 color: white;
107 border: none;
108 border-radius: 4px;
109 cursor: pointer;
110 font-size: 20px;
111}
112
113.todo-item button:hover {
114 background: #da190b;
115}
116
117.todo-item.editing input[type="text"] {
118 flex: 1;
119 padding: 8px;
120 font-size: 16px;
121 border: 1px solid #42b983;
122 border-radius: 4px;
123}
124</style>
Common Gotchas
1. Template Syntax
1<template>
2 <!-- ✅ Correct: v-bind shorthand -->
3 <img :src="imageUrl" :alt="altText" />
4
5 <!-- ✅ Correct: v-on shorthand -->
6 <button @click="handleClick">Click</button>
7
8 <!-- ✅ Correct: v-model -->
9 <input v-model="message" />
10
11 <!-- ❌ Wrong: using .value in template -->
12 <p>{{ count.value }}</p>
13
14 <!-- ✅ Correct: no .value in template -->
15 <p>{{ count }}</p>
16</template>
2. Event Modifiers
1<template>
2 <!-- Prevent default -->
3 <form @submit.prevent="handleSubmit">
4
5 <!-- Stop propagation -->
6 <button @click.stop="handleClick">
7
8 <!-- Key modifiers -->
9 <input @keyup.enter="submit" />
10 <input @keyup.esc="cancel" />
11
12 <!-- Mouse modifiers -->
13 <button @click.right="showContextMenu">
14
15 <!-- System modifiers -->
16 <input @keyup.ctrl.enter="send" />
17
18 <!-- Once modifier -->
19 <button @click.once="doOnce">
20</template>
3. Conditional Rendering
1<template>
2 <!-- v-if: conditionally render element -->
3 <div v-if="isVisible">Visible</div>
4 <div v-else-if="isHidden">Hidden</div>
5 <div v-else>Default</div>
6
7 <!-- v-show: toggle CSS display -->
8 <div v-show="isVisible">Toggle visibility</div>
9
10 <!-- ⚠️ v-if removes from DOM, v-show just hides -->
11 <!-- Use v-if for rare toggles, v-show for frequent toggles -->
12</template>
4. List Rendering
1<template>
2 <!-- ✅ Correct: unique key -->
3 <div v-for="item in items" :key="item.id">
4 {{ item.name }}
5 </div>
6
7 <!-- ❌ Wrong: index as key (avoid if list changes) -->
8 <div v-for="(item, index) in items" :key="index">
9 {{ item.name }}
10 </div>
11
12 <!-- ✅ Correct: with index -->
13 <div v-for="(item, index) in items" :key="item.id">
14 {{ index }}: {{ item.name }}
15 </div>
16</template>
5. Props and Emits
1<script setup lang="ts">
2// ✅ Correct: typed props
3const props = defineProps<{
4 title: string;
5 count?: number; // Optional
6}>();
7
8// ✅ Correct: with defaults
9const props = withDefaults(defineProps<{
10 title: string;
11 count?: number;
12}>(), {
13 count: 0
14});
15
16// ✅ Correct: typed emits
17const emit = defineEmits<{
18 update: [value: string];
19 delete: [id: number];
20}>();
21
22// Emit event
23emit('update', 'new value');
24</script>
Related Snippets
- React Essentials - From Setup to Todo App
Complete React guide from project setup to building a functional Todo …