Nuxt 4

Practical: Xây dựng Todo App với Nuxt 4

Hướng dẫn chi tiết từng bước xây dựng Todo App hoàn chỉnh với Nuxt 4 — composables, useState, components, TypeScript, localStorage.

Tổng quan project

Trong bài này, chúng ta sẽ xây dựng một Todo App hoàn chỉnh từ A đến Z bằng Nuxt 4. Đây là bài hands-on — bạn code theo từng bước, áp dụng tất cả kiến thức đã học ở các section trước vào một project thật.

Mục tiêu: Không chỉ xem code — bạn sẽ hiểu tại sao mỗi dòng code được viết như vậy, cách Nuxt 4 hoạt động bên dưới, và tự tin build project riêng sau khi hoàn thành.

Những gì sẽ học

  • Khởi tạo project Nuxt 4 — hiểu từng file generate ra.
  • TypeScript typesinterface, type, crypto.randomUUID().
  • Composable useTodos — trái tim của app, quản lý state tập trung.
  • useState — shared state SSR-safe, khác ref thế nào.
  • computed — reactive derivation, tự cập nhật khi dependency thay đổi.
  • watch — sync state với localStorage để persist dữ liệu.
  • v-model — two-way binding trong form.
  • defineProps / defineEmits — typed component API.
  • defineModel — Vue 3.4+ syntactic sugar cho v-model component.
  • v-if / v-else — conditional rendering cho edit mode.
  • TransitionGroup — animation khi thêm/xóa item.
  • useHead / useSeoMeta — SEO built-in của Nuxt 4.
  • CSS scoped — style không leak giữa các component.
  • import.meta.client — SSR guard cho code chỉ chạy ở browser.

Cấu trúc project

todo-app/
├── app.vue                    # Root layout
├── pages/
│   └── index.vue              # Main todo page
├── components/
│   ├── TodoForm.vue            # Add new todo
│   ├── TodoList.vue            # List all todos
│   ├── TodoItem.vue            # Single todo row
│   └── TodoFilter.vue          # Filter: all/active/completed
├── composables/
│   └── useTodos.ts             # Todo state management
├── types/
│   └── todo.ts                 # TypeScript types
├── assets/
│   └── css/
│       └── main.css            # Global styles + CSS custom properties
├── app.config.ts               # App configuration
└── nuxt.config.ts              # Nuxt configuration

1. Khởi tạo dự án

Mở terminal và chạy các lệnh sau:

npx nuxi init todo-app
cd todo-app
npm install
npm run dev

Sau khi chạy npx nuxi init todo-app, Nuxt sẽ tạo ra cấu trúc thư mục sau:

todo-app/
├── .nuxt/               # Thư mục build — Nuxt tự quản lý
├── app/                 # Code app của bạn
│   ├── app.vue          # Root component
│   └── assets/          # Static assets
├── node_modules/        # Dependencies
├── .gitignore           # Git ignore rules
├── nuxt.config.ts       # Cấu hình Nuxt
├── package.json         # Project metadata + scripts
├── tsconfig.json        # TypeScript config
└── README.md            # Project readme

Giải thích từng file

package.json — Khai báo tên project, scripts, và dependencies:

{
  "name": "todo-app",
  "type": "module",
  "private": true,
  "scripts": {
    "build": "nuxt build",
    "dev": "nuxt dev",
    "generate": "nuxt generate",
    "preview": "nuxt preview"
  },
  "dependencies": {
    "nuxt": "^4.0.0",
    "vue": "latest"
  }
}
  • "type": "module" — dùng ESM (import/export) thay CommonJS (require).
  • "private": true — chặn không cho publish nhầm lên npm registry.
  • nuxt dev — chế độ development, hot reload (HMR), devtools.
  • nuxt build — build production.
  • nuxt generate — pre-render toàn bộ static HTML (SSG).
  • nuxt preview — xem trước production build local.

nuxt.config.ts — Trái tim cấu hình của app:

// nuxt.config.ts
export default defineNuxtConfig({
  compatibilityDate: '2025-07-01',
  devtools: { enabled: true },
  modules: [],
  css: ['~/assets/css/main.css'],
  app: {
    head: {
      title: 'Todo App',
      meta: [
        { name: 'viewport', content: 'width=device-width, initial-scale=1' }
      ]
    }
  }
})
Giải thích compatibilityDate: Pin behavior của Nuxt tại một thời điểm cụ thể. Khi upgrade Nuxt version mới, các thay đổi behavior chỉ áp dụng nếu compatibilityDate >= ngày release. Giữ cho app không bất ngờ bị vỡ khi upgrade. Đặt là '2025-07-01' là ngày release Nuxt 4 stable.
  • devtools: { enabled: true } — bật Nuxt DevTools (giao diện debug trong browser).
  • modules: [] — danh sách module Nuxt sẽ dùng.
  • css: ['~/assets/css/main.css'] — import global CSS. ~/ là alias trỏ đến thư mục app/.
  • app.head — cài đặt meta tag, title mặc định.

Sau khi chạy npm run dev, mở browser vào http://localhost:3000 — bạn sẽ thấy trang welcome của Nuxt.


2. Định nghĩa TypeScript types

Tạo file app/types/todo.ts:

// app/types/todo.ts

/**
 * Todo item — dữ liệu cốt lõi của app.
 * Dùng `interface` để mô tả hình dạng của object.
 */
export interface Todo {
  /** Unique identifier — dùng crypto.randomUUID() để generate */
  id: string

  /** Nội dung của todo */
  title: string

  /** Trạng thái hoàn thành */
  completed: boolean

  /** Thời gian tạo — dùng để sort hoặc hiển thị */
  createdAt: Date
}

/**
 * Filter type — literal union type.
 * Chỉ có thể là 1 trong 3 giá trị này.
 */
export type TodoFilter = 'all' | 'active' | 'completed'

Giải thích chi tiết

interface vs type:

interface
Object shape
Dùng để mô tả hình dạng của object. Có thể extend (extends), merge declaration. Khuyên dùng interface cho object (public API, component props).
type
Union / primitive / utility
Dùng cho union type ('all' | 'active' | 'completed'), literal type, mapped type, utility type. Linh hoạt hơn nhưng không merge được.
Quy tắc chọn:interface cho object shape (Todo), type cho union/literal (TodoFilter). Nói chung: dùng interface cho đến khi cần feature chỉ có ở type (union, intersection phức tạp).

crypto.randomUUID():

// Tạo ID duy nhất — không trùng lặp, không cần thư viện ngoài
const id = crypto.randomUUID()
// Ví dụ: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
  • Là Web API built-in, hoạt động ở cả browser và Node.js (Nitro server).
  • Tốt hơn Math.random() — UUID là globally unique, không bị collide.
  • Không cần uuid npm package.
  • Trong SSR: Nitro server chạy Node.js >= 19 có crypto.randomUUID() built-in.

createdAt: Date — Lưu ý: Khi serialize qua JSON (localStorage), Date sẽ thành string. Ta sẽ xử lý việc parse lại trong composable.


3. Composable useTodos — Trái tim của app

Tạo file app/composables/useTodos.ts:

// app/composables/useTodos.ts

import type { Todo, TodoFilter } from '~/types/todo'

/**
 * useTodos — composable quản lý toàn bộ state và logic của Todo App.
 *
 * Shared state qua useState → mọi component gọi useTodos() đều
 * đọc/ghi cùng một state, không cần prop drilling hay emit lòng vòng.
 */
export const useTodos = () => {
  // ============================================================
  // STATE — dùng useState cho shared state SSR-safe
  // ============================================================

  /**
   * useState<Todo[]>('todos', () => [])
   *
   * - Key 'todos': unique identifier trong toàn app.
   *   Mọi component gọi useState('todos') đều lấy cùng 1 ref.
   * - Lazy init () => []: chỉ chạy 1 lần khi state chưa tồn tại.
   *   Dùng arrow function để tránh chạy không cần thiết ở SSR.
   */
  const todos = useState<Todo[]>('todos', () => [])

  /**
   * Filter state — cũng share qua useState để TodoList và TodoFilter
   * cùng đọc 1 nguồn filter mà không cần emit qua lại.
   */
  const filter = useState<TodoFilter>('todoFilter', () => 'all')

  // ============================================================
  // LOCALSTORAGE PERSISTENCE
  // ============================================================

  // Load dữ liệu từ localStorage khi app khởi tạo (chỉ ở client)
  if (import.meta.client) {
    try {
      const stored = localStorage.getItem('nuxt-todos')
      if (stored) {
        // Parse JSON và chuyển createdAt từ string thành Date
        const parsed: Todo[] = JSON.parse(stored)
        todos.value = parsed.map(todo => ({
          ...todo,
          createdAt: new Date(todo.createdAt)
        }))
      }
    } catch {
      // Nếu localStorage bị corrupt hoặc không có dữ liệu — bỏ qua
      console.warn('Failed to load todos from localStorage')
    }
  }

  /**
   * watch — theo dõi sự thay đổi của todos và tự động lưu vào localStorage.
   *
   * { deep: true }: theo dõi cả thay đổi bên trong array (add/remove item,
   * thay đổi completed, thay đổi title). Mặc định watch chỉ theo dõi
   * tham chiếu của ref — không phát hiện mutation bên trong.
   */
  if (import.meta.client) {
    watch(todos, (newTodos) => {
      localStorage.setItem('nuxt-todos', JSON.stringify(newTodos))
    }, { deep: true })
  }

  // ============================================================
  // COMPUTED — reactive derivation
  // ============================================================

  /**
   * computed — tự động tính toán lại khi dependency (todos) thay đổi.
   * Giống như Excel formula — thay đổi input, output tự update.
   */
  const activeTodos = computed(() => todos.value.filter(t => !t.completed))
  const completedTodos = computed(() => todos.value.filter(t => t.completed))

  const activeCount = computed(() => activeTodos.value.length)
  const completedCount = computed(() => completedTodos.value.length)

  /**
   * filteredTodos — dựa trên filter state hiện tại, lọc todos tương ứng.
   * Đây là computed được dùng trong TodoList để render đúng danh sách.
   */
  const filteredTodos = computed(() => {
    switch (filter.value) {
      case 'active':
        return activeTodos.value
      case 'completed':
        return completedTodos.value
      default:
        return todos.value
    }
  })

  // ============================================================
  // CRUD OPERATIONS
  // ============================================================

  /**
   * Thêm todo mới.
   * - Dùng crypto.randomUUID() cho id duy nhất.
   * - Đặt createdAt là thời điểm hiện tại.
   * - Tạo object Todo mới và push vào array.
   */
  const addTodo = (title: string) => {
    const trimmed = title.trim()
    if (!trimmed) return  // Không thêm todo rỗng

    const todo: Todo = {
      id: crypto.randomUUID(),
      title: trimmed,
      completed: false,
      createdAt: new Date()
    }
    todos.value.push(todo)
  }

  /**
   * Toggle trạng thái completed của todo.
   * Tìm todo theo id, nếu thấy thì đảo ngược completed.
   */
  const toggleTodo = (id: string) => {
    const todo = todos.value.find(t => t.id === id)
    if (todo) {
      todo.completed = !todo.completed
    }
  }

  /**
   * Xóa todo theo id.
   * Dùng filter để tạo array mới không chứa id đó.
   * Gán lại cho todos.value để trigger reactivity.
   */
  const removeTodo = (id: string) => {
    todos.value = todos.value.filter(t => t.id !== id)
  }

  /**
   * Chỉnh sửa title của todo.
   * Tìm todo theo id, cập nhật title mới.
   */
  const editTodo = (id: string, title: string) => {
    const trimmed = title.trim()
    if (!trimmed) {
      // Nếu title rỗng sau khi trim — xóa todo luôn
      removeTodo(id)
      return
    }

    const todo = todos.value.find(t => t.id === id)
    if (todo) {
      todo.title = trimmed
    }
  }

  /**
   * Xóa tất cả todo đã hoàn thành.
   * Dùng filter để giữ lại những todo chưa completed.
   */
  const clearCompleted = () => {
    todos.value = todos.value.filter(t => !t.completed)
  }

  // ============================================================
  // RETURN — chỉ expose những gì component cần
  // ============================================================

  return {
    // State
    todos,
    filter,

    // Computed
    activeTodos,
    completedTodos,
    activeCount,
    completedCount,
    filteredTodos,

    // Actions
    addTodo,
    toggleTodo,
    removeTodo,
    editTodo,
    clearCompleted
  }
}

Giải thích sâu từng dòng

useState vs ref — tại sao dùng useState?
  • useState('todos', () => []): Tạo một ref được share giữa tất cả component gọi cùng key 'todos'. SSR-safe — mỗi request server có state riêng.
  • ref([]) trong module scope: State sẽ shared giữa các request ở SSR — request A thay state request B. Rất nguy hiểm.
  • ref([]) trong component: Mỗi component có state riêng — không share được giữa TodoForm và TodoList.

Key 'todos':

  • Unique identifier cho state này trong toàn app.
  • Mọi component gọi useState('todos') đều lấy cùng một reactive ref.
  • Nếu sai key khác ('my-todos'), sẽ tạo state khác — không share được.

Lazy init () => []:

  • Truyền function thay vì giá trị trực tiếp để không chạy khi state đã tồn tại.
  • Nuxt gọi init function chỉ 1 lần khi key chưa được đăng ký.
  • Nếu truyền [] trực tiếp, sẽ tạo array mới mỗi lần không cần thiết.

computed:

  • derived state — tự động tính lại khi dependency thay đổi.
  • Cache kết quả — chỉ re-evaluate khi dependency thực sự thay đổi.
  • Read-only — không thể gán trực tiếp computed.value = ....
  • Giống như useMemo trong React nhưng tự động track dependency.

watch với { deep: true }:

  • Mặc định watch chỉ theo dõi tham chiếu của ref (todos.value === newRef).
  • Với array, khi push/remove item, tham chiếu không đổi → watch không chạy.
  • { deep: true }: theo dõi recursive — phát hiện thay đổi bên trong array/object.
  • Trade-off: deep watch tốn performance hơn cho array lớn. Với todo app (vài trăm item) — không đáng kể.

import.meta.client:

  • SSR guard: code trong block này chỉ chạy ở browser.
  • localStorage không tồn tại ở server (Node.js) — nếu không guard sẽ throw error.
  • import.meta.server — ngược lại, chỉ chạy ở server.
  • Đây là Vite built-in, không phải Nuxt-specific.

4. Components

4.1 TodoForm.vue — Form thêm todo mới

Tạo file app/components/TodoForm.vue:

<script setup lang="ts">
/**
 * TodoForm — component form để thêm todo mới.
 *
 * Sử dụng:
 * - ref: local state cho input text.
 * - useTodos: lấy addTodo từ composable.
 * - v-model: two-way binding giữa input và ref.
 * - @submit.prevent: ngăn form reload page.
 */

const newTodo = ref('')
const { addTodo } = useTodos()

/**
 * Xử lý khi form submit.
 * - Trim để loại bỏ khoảng trắng thừa ở đầu/cuối.
 * - Nếu rỗng sau trim — không làm gì (tránh thêm todo rỗng).
 * - Gọi addTodo, sau đó reset input.
 */
const handleSubmit = () => {
  const trimmed = newTodo.value.trim()
  if (!trimmed) return

  addTodo(trimmed)
  newTodo.value = ''
}
</script>

<template>
  <form
    class="todo-form"
    @submit.prevent="handleSubmit"
  >
    <input
      v-model="newTodo"
      type="text"
      class="todo-input"
      placeholder="What needs to be done?"
      autocomplete="off"
    />
    <button
      type="submit"
      class="btn-add"
      :disabled="!newTodo.trim()"
    >
      Add
    </button>
  </form>
</template>

<style scoped>
.todo-form {
  display: flex;
  gap: 8px;
  margin-bottom: 24px;
}

.todo-input {
  flex: 1;
  padding: 12px 16px;
  font-size: 16px;
  border: 2px solid var(--color-border);
  border-radius: var(--radius);
  background: var(--color-bg);
  color: var(--color-text);
  outline: none;
  transition: border-color 0.2s;
}

.todo-input:focus {
  border-color: var(--color-primary);
}

.todo-input::placeholder {
  color: var(--color-text-muted);
}

.btn-add {
  padding: 12px 24px;
  font-size: 16px;
  font-weight: 600;
  border: none;
  border-radius: var(--radius);
  background: var(--color-primary);
  color: white;
  cursor: pointer;
  transition: opacity 0.2s, transform 0.1s;
}

.btn-add:hover:not(:disabled) {
  opacity: 0.9;
}

.btn-add:active:not(:disabled) {
  transform: scale(0.97);
}

.btn-add:disabled {
  opacity: 0.4;
  cursor: not-allowed;
}
</style>

Giải thích chi tiết

v-model="newTodo":

  • Two-way binding: Khi user gõ vào input → newTodo.value tự động cập nhật. Khi newTodo.value thay đổi (reset sau submit) → input tự động clear.
  • Là syntactic sugar cho :value="newTodo" + @input="newTodo = $event.target.value".
  • Chỉ dùng với form element (<input>, <textarea>, <select>).

@submit.prevent:

  • .prevent là event modifier — tương đương event.preventDefault().
  • Nếu không có .prevent, form sẽ reload page (hành vi mặc định của HTML form submit).
  • Các modifier Vue khác: .stop, .once, .capture, .self, .passive.

:disabled="!newTodo.trim()":

  • Dynamic attribute binding: là shorthand cho v-bind:.
  • Nút Add bị disable khi input rỗng (sau trim).
  • Tại sao dùng .trim() trong template? Check real-time — ngay khi user gõ space rồi xóa, nút enable/disable đúng.

<style scoped>:

  • Scoped CSS: style trong component này chỉ apply cho template của component này.
  • Nuxt/Vue tự động add data-v-xxxxxx attribute vào element và CSS selector để isolate.
  • Không lo style leak sang component khác.
  • MUỐN override: dùng :deep(.selector) hoặc :global(.selector).

autocomplete="off":

  • Tắt autocomplete của browser cho input này — tránh dropdown suggestion không liên quan chèn vào UI.

4.2 TodoItem.vue — Một dòng todo

Tạo file app/components/TodoItem.vue:

<script setup lang="ts">
import type { Todo } from '~/types/todo'

/**
 * TodoItem — component cho một dòng todo.
 *
 * Giao tiếp với parent qua:
 * - defineProps: nhận dữ liệu todo từ parent.
 * - defineEmits: emit event toggle, remove, edit lên parent.
 *
 * Có 2 chế độ:
 * - View mode: hiển thị title + checkbox + nút xóa.
 * - Edit mode: input text để chỉnh sửa title.
 */

/** Props — todo object từ parent (TodoList) */
const props = defineProps<{
  todo: Todo
}>()

/** Emits — typed event để parent lắng nghe */
const emit = defineEmits<{
  toggle: [id: string]
  remove: [id: string]
  edit: [id: string, title: string]
}>()

/** Local state cho edit mode */
const isEditing = ref(false)
const editText = ref('')

/**
 * Bắt đầu edit mode.
 * Lưu title hiện tại vào editText để user có thể chỉnh sửa.
 */
const startEdit = () => {
  isEditing.value = true
  editText.value = props.todo.title
}

/**
 * Lưu kết quả chỉnh sửa.
 * Nếu title rỗng sau trim → không làm gì.
 * Gọi emit edit để cập nhật ở composable.
 */
const saveEdit = () => {
  const trimmed = editText.value.trim()
  if (!trimmed) {
    // Nếu rỗng sau trim — xóa todo
    emit('remove', props.todo.id)
    isEditing.value = false
    return
  }

  // Chỉ emit nếu title thực sự thay đổi
  if (trimmed !== props.todo.title) {
    emit('edit', props.todo.id, trimmed)
  }
  isEditing.value = false
}

/** Hủy edit mode — quay lại view mode, không lưu thay đổi */
const cancelEdit = () => {
  isEditing.value = false
}
</script>

<template>
  <div
    class="todo-item"
    :class="{
      completed: todo.completed,
      editing: isEditing
    }"
  >
    <!-- Checkbox: toggle completed -->
    <input
      type="checkbox"
      class="todo-checkbox"
      :checked="todo.completed"
      @change="emit('toggle', todo.id)"
    />

    <!-- VIEW MODE: hiển thị title -->
    <label
      v-if="!isEditing"
      class="todo-title"
      @dblclick="startEdit"
    >
      {{ todo.title }}
    </label>

    <!-- EDIT MODE: input chỉnh sửa -->
    <input
      v-else
      ref="editInputRef"
      v-model="editText"
      type="text"
      class="edit-input"
      @keyup.enter="saveEdit"
      @keyup.escape="cancelEdit"
      @blur="saveEdit"
    />

    <!-- Nút xóa -->
    <button
      class="btn-delete"
      @click="emit('remove', todo.id)"
      aria-label="Delete todo"
    >
      ×
    </button>
  </div>
</template>

<style scoped>
.todo-item {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 12px 16px;
  border-bottom: 1px solid var(--color-border);
  transition: background-color 0.2s;
}

.todo-item:hover {
  background: var(--color-bg-hover);
}

.todo-item.completed .todo-title {
  text-decoration: line-through;
  color: var(--color-text-muted);
}

.todo-item.editing {
  background: var(--color-bg-active);
}

.todo-checkbox {
  width: 20px;
  height: 20px;
  accent-color: var(--color-primary);
  cursor: pointer;
  flex-shrink: 0;
}

.todo-title {
  flex: 1;
  font-size: 16px;
  cursor: pointer;
  user-select: none;
  word-break: break-word;
}

.edit-input {
  flex: 1;
  padding: 8px 12px;
  font-size: 16px;
  border: 2px solid var(--color-primary);
  border-radius: var(--radius);
  background: var(--color-bg);
  color: var(--color-text);
  outline: none;
}

.btn-delete {
  width: 32px;
  height: 32px;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 20px;
  font-weight: 300;
  border: none;
  border-radius: 50%;
  background: transparent;
  color: var(--color-text-muted);
  cursor: pointer;
  transition: all 0.2s;
  flex-shrink: 0;
  opacity: 0;
}

.todo-item:hover .btn-delete {
  opacity: 1;
}

.btn-delete:hover {
  background: var(--color-danger);
  color: white;
}
</style>

Giải thích chi tiết

defineProps<{ todo: Todo }>():

  • Typed props — TypeScript sẽ check kiểu khi parent truyền prop.
  • Không cần import defineProps — compiler macro của Vue, auto-available.
  • Runtime validation: chỉ check type ở compile time. Muốn runtime validation dùng object syntax defineProps({ todo: { type: Object, required: true } }).

defineEmits<{ toggle: [id: string]; remove: [id: string]; edit: [id: string, title: string] }>():

  • Typed emits — TypeScript sẽ check argument khi emit('toggle', ...).
  • Cú pháp: { eventName: [arg1Type, arg2Type, ...] }.
  • Cũng là compiler macro — không cần import.

v-if="!isEditing" / v-else:

  • Conditional rendering: Vue chỉ render một trong hai nhánh.
  • v-if thực sự remove/add element khỏi DOM.
  • Khác với v-show — chỉ toggle display: none, element vẫn trong DOM.
  • Dùng v-if ở đây vì edit mode ít khi được dùng — tối ưu DOM.

@dblclick="startEdit":

  • Bắt sự kiện double-click để vào edit mode.
  • Pattern UX quen thuộc từ TodoMVC — double-click để edit.

@keyup.enter="saveEdit":

  • Keyboard shortcut — nhấn Enter để lưu.
  • .enter là key modifier — chỉ trigger khi phím Enter được nhấn.
  • Các key modifier khác: .esc, .space, .tab, .delete, .up, .down, .left, .right.
  • Có thể chain: @keyup.ctrl.enter="..." — Ctrl+Enter.

@keyup.escape="cancelEdit":

  • Nhấn Escape để hủy edit, quay lại view mode.
  • Không lưu thay đổi khi hủy.

@blur="saveEdit":

  • Khi user click ra ngoài input (focus lost) → tự động lưu.
  • Kết hợp với Enter tạo UX tốt: Enter hoặc click ra ngoài đều lưu, Escape để hủy.

:class="{ completed: todo.completed, editing: isEditing }":

  • Object syntax cho dynamic class.
  • { className: booleanExpression } — class được add khi expression = true.
  • Có thể kết hợp với static class: class="todo-item" :class="{ completed: ... }".
  • Array syntax: :class="['base-class', isActive && 'active']".

CSS .todo-item:hover .btn-delete { opacity: 1 }:

  • Nút xóa chỉ hiển thị khi hover vào dòng todo.
  • Giữ UI sạch sẽ, không bị clutter bởi quá nhiều nút xóa.
  • opacity: 0 mặc định → opacity: 1 khi hover cha.

4.3 TodoFilter.vue — Bộ lọc todo

Tạo file app/components/TodoFilter.vue:

<script setup lang="ts">
import type { TodoFilter } from '~/types/todo'

/**
 * TodoFilter — bộ lọc all / active / completed.
 *
 * Sử dụng defineModel — Vue 3.4+ syntactic sugar cho v-model trong component.
 * defineModel tạo một ref đồng bộ 2 chiều với parent qua v-model.
 */

/**
 * defineModel<TodoFilter>({ required: true })
 *
 * - Tạo ra một ref mà khi thay đổi sẽ tự động emit update:modelValue.
 * - Parent dùng: <TodoFilter v-model="filter" />
 * - Không cần defineProps + defineEmits thủ công nữa.
 * - required: true — bắt buộc parent phải truyền v-model.
 */
const filter = defineModel<TodoFilter>({ required: true })

/** Danh sách các filter option */
const filters: { label: string; value: TodoFilter }[] = [
  { label: 'All', value: 'all' },
  { label: 'Active', value: 'active' },
  { label: 'Completed', value: 'completed' }
]
</script>

<template>
  <div class="todo-filters">
    <button
      v-for="f in filters"
      :key="f.value"
      class="filter-btn"
      :class="{ active: filter === f.value }"
      @click="filter = f.value"
    >
      {{ f.label }}
    </button>
  </div>
</template>

<style scoped>
.todo-filters {
  display: flex;
  gap: 4px;
  justify-content: center;
  margin-bottom: 16px;
}

.filter-btn {
  padding: 6px 16px;
  font-size: 14px;
  font-weight: 500;
  border: 1px solid var(--color-border);
  border-radius: var(--radius);
  background: transparent;
  color: var(--color-text-muted);
  cursor: pointer;
  transition: all 0.2s;
}

.filter-btn:hover {
  color: var(--color-text);
  border-color: var(--color-text-muted);
}

.filter-btn.active {
  background: var(--color-primary);
  color: white;
  border-color: var(--color-primary);
}
</style>

Giải thích chi tiết

defineModel<TodoFilter>({ required: true }):

  • Giới thiệu từ Vue 3.4 — là syntactic sugar cho v-model trong component.
  • Tự động tạo:
    • Prop modelValue (kiểu TodoFilter).
    • Emit update:modelValue (tự động gọi khi gán filter = ...).
  • Trước Vue 3.4, bạn phải viết:
    const props = defineProps<{ modelValue: TodoFilter }>()
    const emit = defineEmits<{ 'update:modelValue': [value: TodoFilter] }>()
    // Gán: emit('update:modelValue', newValue)
    
  • Với defineModel, chỉ cần gán trực tiếp filter = f.value — Vue tự động emit.
defineModel vs defineProps + defineEmits:
  • defineModel = ít code hơn, sạch hơn cho v-model pattern.
  • defineProps + defineEmits = linh hoạt hơn, nhiều emit event khác nhau.
  • Với component chỉ cần 1 v-model, dùng defineModel. Với component cần nhiều emit event khác (toggle, remove, edit) — dùng defineEmits.

v-for="f in filters" :key="f.value":

  • v-for lặp qua array để render danh sách filter button.
  • :keybắt buộc khi dùng v-for. Giúp Virtual DOM có thể track identity của từng element → tối ưu re-render.
  • Tại sao không dùng index làm key? Khi array thay đổi thứ tự, index không còn trỏ đến đúng element → DOM update sai. Dùng unique value (f.value).

@click="filter = f.value":

  • Khi click button, gán giá trị filter.
  • defineModel tự động emit update:modelValue → parent nhận giá trị mới.
  • Không cần emit('update:modelValue', f.value) — defineModel lo phần đó.

4.4 TodoList.vue — Danh sách todo

Tạo file app/components/TodoList.vue:

<script setup lang="ts">
/**
 * TodoList — component danh sách todo.
 *
 * Chức năng:
 * - Lọc todo theo filter (all/active/completed).
 * - Render list qua TodoItem.
 * - Hiển thị empty state khi không có todo.
 * - Footer: item count + clear completed.
 * - TransitionGroup animation khi thêm/xóa item.
 */

const {
  todos,
  filteredTodos,
  activeCount,
  completedCount,
  toggleTodo,
  removeTodo,
  editTodo,
  clearCompleted
} = useTodos()
</script>

<template>
  <div class="todo-list-wrapper">
    <!-- Bộ lọc -->
    <TodoFilter v-model="filter" />

    <!-- Empty state -->
    <div
      v-if="filteredTodos.length === 0"
      class="empty-state"
    >
      <p v-if="todos.length === 0">
        No todos yet! Add one above.
      </p>
      <p v-else>
        No {{ filter === 'active' ? 'active' : 'completed' }} todos.
      </p>
    </div>

    <!-- Danh sách todo với animation -->
    <TransitionGroup
      v-else
      name="todo-list"
      tag="ul"
      class="todo-list"
    >
      <li
        v-for="todo in filteredTodos"
        :key="todo.id"
      >
        <TodoItem
          :todo="todo"
          @toggle="toggleTodo"
          @remove="removeTodo"
          @edit="editTodo"
        />
      </li>
    </TransitionGroup>

    <!-- Footer -->
    <footer
      v-if="todos.length > 0"
      class="todo-footer"
    >
      <span class="items-left">
        {{ activeCount }} {{ activeCount === 1 ? 'item' : 'items' }} left
      </span>

      <button
        v-if="completedCount > 0"
        class="btn-clear"
        @click="clearCompleted"
      >
        Clear completed ({{ completedCount }})
      </button>
    </footer>
  </div>
</template>

<style scoped>
.todo-list-wrapper {
  background: var(--color-bg);
  border-radius: var(--radius);
  box-shadow: var(--shadow);
  overflow: hidden;
}

.todo-list {
  list-style: none;
  margin: 0;
  padding: 0;
}

.empty-state {
  padding: 48px 24px;
  text-align: center;
  color: var(--color-text-muted);
  font-size: 16px;
}

.todo-footer {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 12px 16px;
  font-size: 14px;
  color: var(--color-text-muted);
  border-top: 1px solid var(--color-border);
}

.items-left {
  font-weight: 500;
}

.btn-clear {
  padding: 4px 12px;
  font-size: 14px;
  border: none;
  border-radius: var(--radius);
  background: transparent;
  color: var(--color-text-muted);
  cursor: pointer;
  transition: all 0.2s;
}

.btn-clear:hover {
  background: var(--color-danger);
  color: white;
}

/* ============================================================
   TransitionGroup animations — thêm/xóa todo item
   ============================================================ */

/* Animation khi item enter (được thêm vào) */
.todo-list-enter-active {
  transition: all 0.4s ease;
}

/* Trạng thái bắt đầu khi enter */
.todo-list-enter-from {
  opacity: 0;
  transform: translateX(-20px);
}

/* Animation khi item leave (bị xóa) */
.todo-list-leave-active {
  transition: all 0.3s ease;
  position: absolute; /* Tránh chiếm không gian khi đang leave */
}

/* Trạng thái kết thúc khi leave */
.todo-list-leave-to {
  opacity: 0;
  transform: translateX(30px);
}

/* Move transition — khi các item còn lại tự động di chuyển lên */
.todo-list-move {
  transition: transform 0.3s ease;
}
</style>

Giải thích chi tiết

v-if="filteredTodos.length === 0":

  • Hiển thị empty state khi không có todo nào thỏa filter.
  • Hai empty state khác nhau: "Chưa có todo nào" vs "Không có todo active/completed".
  • Logic: todos.length === 0 → chưa có todo nào. Ngược lại → có todo nhưng không match filter.

v-for="todo in filteredTodos" :key="todo.id":

  • key là bắt buộc — không có key, Vue sẽ gặp lỗi hoặc hành vi không đoán trước.
  • Key giúp Vue track identity của từng element trong Virtual DOM.
  • Khi array thay đổi (thêm/xóa/filter), Vue chỉ re-render element có key thay đổi.
  • Luôn dùng unique, stable identifiertodo.id là hoàn hảo. Không dùng index.

<TransitionGroup>:

  • Là component Vue built-in để animate list — khi item được thêm, xóa, hoặc di chuyển.
  • Khác với <Transition> — chỉ animate 1 element.
  • name="todo-list" — prefix cho CSS class transition. Vue tự động add/remove các class:
    • todo-list-enter-from, todo-list-enter-active, todo-list-enter-to.
    • todo-list-leave-from, todo-list-leave-active, todo-list-leave-to.
    • todo-list-move — khi các item còn lại di chuyển để lấp khoảng trống.
  • tag="ul" — render thành <ul> element (mặc định là <span>).
TransitionGroup CSS các class cần biết:
ClassThời điểm
*-enter-fromTrạng thái bắt đầu khi element được thêm (frame 0)
*-enter-activeTrong suốt quá trình enter — đặt transition ở đây
*-enter-toTrạng thái kết thúc enter — remove sau khi xong
*-leave-fromTrạng thái bắt đầu khi bị xóa
*-leave-activeTrong suốt quá trình leave — đặt transition ở đây
*-leave-toTrạng thái kết thúc leave
*-moveKhi các element còn lại di chuyển — đặt transition ở đây

Footer logic:

  • <footer v-if="todos.length > 0"> — chỉ hiển thị footer khi có ít nhất 1 todo.
  • <span>{{ activeCount }} items left</span> — hiển thị số lượng todo chưa hoàn thành.
  • <button v-if="completedCount > 0"> — nút clear completed chỉ hiện khi có ít nhất 1 todo completed.

5. Pages — index.vue

Tạo file app/pages/index.vue:

<script setup lang="ts">
/**
 * index.vue — trang chủ của Todo App.
 *
 * File-based routing: `app/pages/index.vue` → route `/`.
 * Không cần khai báo route — Nuxt tự detect.
 *
 * Sử dụng:
 * - useHead: set document head (title, meta).
 * - useSeoMeta: type-safe SEO meta tags.
 */

useHead({
  title: 'Todo App — Nuxt 4',
  htmlAttrs: { lang: 'en' },
  link: [
    { rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' }
  ]
})

useSeoMeta({
  title: 'Todo App — Built with Nuxt 4',
  description: 'A simple, beautiful todo app built with Nuxt 4. Features: add, edit, filter, localStorage persistence.',
  ogTitle: 'Todo App — Nuxt 4',
  ogDescription: 'A simple todo app built with Nuxt 4',
  ogType: 'website',
  twitterCard: 'summary'
})
</script>

<template>
  <div class="todo-app-container">
    <header class="app-header">
      <h1 class="app-title">Todo App</h1>
      <p class="app-subtitle">Built with Nuxt 4</p>
    </header>

    <main class="app-main">
      <TodoForm />
      <TodoList />
    </main>

    <footer class="app-footer">
      <p>
        Double-click to edit a todo &middot;
        Press Enter to save &middot;
        Press Escape to cancel
      </p>
    </footer>
  </div>
</template>

<style scoped>
.todo-app-container {
  max-width: 560px;
  margin: 60px auto;
  padding: 0 20px;
}

.app-header {
  text-align: center;
  margin-bottom: 32px;
}

.app-title {
  font-size: 48px;
  font-weight: 300;
  color: var(--color-primary);
  margin: 0 0 8px;
  letter-spacing: -1px;
}

.app-subtitle {
  font-size: 14px;
  color: var(--color-text-muted);
  margin: 0;
}

.app-main {
  margin-bottom: 24px;
}

.app-footer {
  text-align: center;
  color: var(--color-text-muted);
  font-size: 13px;
  line-height: 1.6;
}
</style>

Giải thích chi tiết

useHead:

  • Nuxt composable để thiết lập document head (title, meta, link, script...).
  • Hoạt động cả SSR và client — server render head đúng, client hydrate.
  • Có thể dùng ở mọi component — các head sẽ merge lại.
  • htmlAttrs: { lang: 'en' } — set <html lang="en">.

useSeoMeta:

  • Type-safe SEO meta tags — chỉ cho phép các tag SEO hợp lệ.
  • Tự động generate Open Graph, Twitter Card tags.
  • Support reactive value (getter function):
    useSeoMeta({ title: () => `Todo — ${dynamicTitle.value}` })
    
  • Khác useHead ở chỗ type-safe — chỉ các meta tag liên quan SEO.

File-based routing:

  • app/pages/index.vue → route /.
  • app/pages/about.vue → route /about.
  • app/pages/users/[id].vue → route /users/:id.
  • Không cần vue-router config — Nuxt quản lý hoàn toàn.

6. Root Layout — app.vue

Cập nhật app/app.vue để làm root layout:

<template>
  <div class="app-root">
    <NuxtPage />
  </div>
</template>

<style>
/* Global styles — KHÔNG scoped để apply toàn app */
*,
*::before,
*::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
    'Helvetica Neue', Arial, sans-serif;
  background: var(--color-bg-body);
  color: var(--color-text);
  line-height: 1.5;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}
</style>

<NuxtPage />:

  • Là component built-in của Nuxt — render page tương ứng với route hiện tại.
  • Tương đương <RouterView /> trong Vue Router nhưng thêm transition + keep-alive.
  • Nếu app có layout: <NuxtLayout> wrap <NuxtPage>.

7. Global CSS — Theming

Tạo file app/assets/css/main.css:

/* app/assets/css/main.css */

/* ============================================================
   CSS Custom Properties — Theme variables
   Thay đổi theme bằng cách override các biến này
   ============================================================ */

:root {
  /* Colors */
  --color-primary: #6c5ce7;
  --color-primary-hover: #5a4bd1;
  --color-danger: #e74c3c;
  --color-danger-hover: #c0392b;
  --color-success: #27ae60;

  /* Background */
  --color-bg-body: #f5f6fa;
  --color-bg: #ffffff;
  --color-bg-hover: #f8f9fa;
  --color-bg-active: #f0f0ff;

  /* Text */
  --color-text: #2d3436;
  --color-text-muted: #95a5a6;

  /* Border */
  --color-border: #e0e0e0;

  /* Radius */
  --radius: 8px;

  /* Shadow */
  --shadow: 0 2px 16px rgba(0, 0, 0, 0.08);
}

/* Dark mode — kích hoạt khi user chọn dark mode ở OS */
@media (prefers-color-scheme: dark) {
  :root {
    --color-primary: #a29bfe;
    --color-primary-hover: #8b83e6;
    --color-danger: #ff7675;
    --color-danger-hover: #d63031;
    --color-success: #55efc4;

    --color-bg-body: #1a1a2e;
    --color-bg: #16213e;
    --color-bg-hover: #1a2744;
    --color-bg-active: #1e2d50;

    --color-text: #e0e0e0;
    --color-text-muted: #7f8c8d;

    --color-border: #2d3436;

    --shadow: 0 2px 16px rgba(0, 0, 0, 0.3);
  }
}

Giải thích chi tiết

CSS Custom Properties (variables):

  • Khai báo trong :root (tương đương <html>) — có thể dùng ở mọi CSS selector.
  • Thay đổi theme bằng cách override variables — không cần sửa từng component.
  • Browser support: tất cả browser hiện đại (IE không hỗ trợ).

@media (prefers-color-scheme: dark):

  • CSS media query — tự động detect user chọn dark mode trong OS settings.
  • Không cần JavaScript — CSS tự switch.
  • Muốn manual toggle dark mode: add class .dark vào <html> và override variables trong html.dark.

8. App Configuration — app.config.ts

Tạo file app/app.config.ts:

// app/app.config.ts

export default defineAppConfig({
  app: {
    name: 'Nuxt 4 Todo App',
    version: '1.0.0'
  },
  ui: {
    primary: 'purple',
    animations: true
  }
})
app.config.ts vs nuxt.config.ts:
  • app.config.ts: reactive config exposed to client — dùng cho theme, UI settings. Có thể truy cập qua useAppConfig().
  • nuxt.config.ts: build-time + runtime config — không reactive. Dùng cho routing, modules, server settings.
  • Quan trọng: Không đặt secret trong app.config.ts — nó exposed to client bundle.

9. Tổng kết — Toàn bộ flow của app

Data flow trong Todo App:
  1. User gõ vào TodoFormv-model="newTodo" cập nhật local ref.
  2. User nhấn Enter/AddhandleSubmit() gọi addTodo(title) trong useTodos.
  3. addTodo push object Todo mới vào todos (useState array).
  4. filteredTodos (computed) tự động tính lại → TodoList re-render.
  5. watch(todos, ...) phát hiện thay đổi → tự động save vào localStorage.
  6. User click checkbox toggle → TodoItem emit toggle(id)toggleTodo(id) trong useTodos.
  7. User double-click todo → TodoItem vào edit mode → saveEdit() emit edit(id, title).
  8. User click filter → TodoFilter cập nhật filter qua defineModelfilteredTodos re-compute.
  9. User click Clear completedclearCompleted() xóa todos đã hoàn thành.

Tính năng Nuxt 4 được áp dụng

Tính năngFileGiải thích
Auto-importTất cảref, computed, watch, useState, useHead, useSeoMeta — không cần import
File-based routingpages/index.vueTự động trở thành route /
Components auto-importTodoForm, TodoList, TodoItem, TodoFilterDùng trực tiếp trong template không cần import
Composables auto-importuseTodosExport named function → auto-available ở mọi component
useStateuseTodos.tsShared state SSR-safe
TypeScripttypes/todo.tsType safety full-stack
useHead / useSeoMetapages/index.vueSEO built-in, SSR + client
Scoped CSSTất cả componentStyle không leak
TransitionGroupTodoList.vueAnimation thêm/xóa item
defineModelTodoFilter.vueVue 3.4+ v-model sugar

10. Bonus — Mở rộng và cải tiến

Sau khi hoàn thành Todo App cơ bản, đây là một số ý tưởng để mở rộng:

10.1 Keyboard shortcut (Ctrl+Enter để thêm)

Trong TodoForm.vue, thêm keyboard listener:

<script setup lang="ts">
// Sử dụng useEventListener từ VueUse (hoặc onMounted + addEventListener)
onMounted(() => {
  const handler = (e: KeyboardEvent) => {
    if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
      handleSubmit()
    }
  }
  window.addEventListener('keydown', handler)
  onUnmounted(() => window.removeEventListener('keydown', handler))
})
</script>

10.2 Drag and drop reorder

Sử dụng thư viện @vueuse/integrations + sortablejs:

npm install sortablejs
npm install @vueuse/integrations
<script setup lang="ts">
import { useSortable } from '@vueuse/integrations/useSortable'

const el = ref<HTMLElement>()
useSortable(el, todos, {
  animation: 150,
  onEnd: () => { /* save order */ }
})
</script>

10.3 Dark mode toggle (manual)

Thêm component ThemeToggle.vue:

<script setup lang="ts">
const isDark = useDark()
const toggleDark = useToggle(isDark)
</script>
<template>
  <button @click="toggleDark()">
    {{ isDark ? '☀️' : '🌙' }}
  </button>
</template>

Cài đặt @vueuse/nuxt:

npm install @vueuse/nuxt
# Thêm vào nuxt.config.ts modules: ['@vueuse/nuxt']

10.4 Toast notifications

Tạo composable useToast.ts:

export const useToast = () => {
  const toasts = useState<Array<{ id: string; message: string; type: 'success' | 'error' }>>('toasts', () => [])

  const addToast = (message: string, type: 'success' | 'error' = 'success') => {
    const id = crypto.randomUUID()
    toasts.value.push({ id, message, type })
    setTimeout(() => {
      toasts.value = toasts.value.filter(t => t.id !== id)
    }, 3000)
  }

  return { toasts, addToast }
}

10.5 Due dates và priority

Mở rộng types/todo.ts:

export type Priority = 'low' | 'medium' | 'high'

export interface Todo {
  id: string
  title: string
  completed: boolean
  createdAt: Date
  dueDate?: Date        // Optional — ngày hết hạn
  priority: Priority     // Độ ưu tiên
}

11. Kiểm tra kết quả

Sau khi code xong, chạy npm run dev và mở http://localhost:3000. Bạn nên thấy:

  1. Form Add ở đầu trang — gõ text vào và nhấn Enter hoặc click Add.
  2. Todo item xuất hiện bên dưới — click checkbox để toggle completed.
  3. Filter buttons (All / Active / Completed) — lọc danh sách todo.
  4. Double-click vào todo title để edit — Enter/Escape để save/hủy.
  5. Hover vào dòng todo để thấy nút xóa (dấu ×).
  6. Footer hiển thị số lượng active items + Clear completed.
  7. Reload page — todo vẫn còn vì đã save vào localStorage.
  8. Animation — thêm/xóa todo có transition mượt mà.
Chúc mừng! Bạn đã hoàn thành Todo App hoàn chỉnh với Nuxt 4.Đây là project thực tế đầu tiên — áp dụng tất cả kiến thức Nuxt 4: useState shared state, computed reactive derivation, watch localStorage persistence, file-based routing, component auto-import, TypeScript types, defineModel, TransitionGroup, useHead/useSeoMeta SEO.Từ đây, bạn có thể tự tin xây dựng các project Nuxt 4 phức tạp hơn.
Quay lại Nuxt 4 để xem lại các section lý thuyết, hoặc Tự học để khám phá chủ đề khác.

© 2026 .NET Fresher Guide. All rights reserved.

Về Trang Web

Hướng dẫn toàn diện để chuẩn bị phỏng vấn vị trí .NET Fresher với nội dung từ lý thuyết đến thực hành.