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

 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>
 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