Practical: Xây dựng Todo App với Nuxt 4
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.
Những gì sẽ học
- Khởi tạo project Nuxt 4 — hiểu từng file generate ra.
- TypeScript types —
interface,type,crypto.randomUUID(). - Composable
useTodos— trái tim của app, quản lý state tập trung. useState— shared state SSR-safe, khácrefthế nào.computed— reactive derivation, tự cập nhật khi dependency thay đổi.watch— sync state vớilocalStorageđể 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' }
]
}
}
})
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ụcapp/.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:
extends), merge declaration. Khuyên dùng interface cho object (public API, component props).'all' | 'active' | 'completed'), literal type, mapped type, utility type. Linh hoạt hơn nhưng không merge được.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
uuidnpm 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:
- Là 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ư
useMemotrong React nhưng tự động track dependency.
watch với { deep: true }:
- Mặc định
watchchỉ 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.
localStoragekhô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.valuetự động cập nhật. KhinewTodo.valuethay đổ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:
.preventlà event modifier — tương đươngevent.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 chov-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-xxxxxxattribute 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-ifthực sự remove/add element khỏi DOM.- Khác với
v-show— chỉ toggledisplay: 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.
.enterlà 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: 0mặc định →opacity: 1khi 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-modeltrong component. - Tự động tạo:
- Prop
modelValue(kiểuTodoFilter). - Emit
update:modelValue(tự động gọi khi gánfilter = ...).
- Prop
- 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ếpfilter = 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ùngdefineEmits.
v-for="f in filters" :key="f.value":
v-forlặp qua array để render danh sách filter button.:key— bắt buộc khi dùngv-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.
defineModeltự động emitupdate: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":
- ⭐
keylà 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 identifier —
todo.idlà hoàn hảo. Không dùngindex.
<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>).
| Class | Thời điểm |
|---|---|
*-enter-from | Trạng thái bắt đầu khi element được thêm (frame 0) |
*-enter-active | Trong suốt quá trình enter — đặt transition ở đây |
*-enter-to | Trạng thái kết thúc enter — remove sau khi xong |
*-leave-from | Trạng thái bắt đầu khi bị xóa |
*-leave-active | Trong suốt quá trình leave — đặt transition ở đây |
*-leave-to | Trạng thái kết thúc leave |
*-move | Khi 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 ·
Press Enter to save ·
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-routerconfig — 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
.darkvào<html>và override variables tronghtml.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 quauseAppConfig().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
- User gõ vào TodoForm →
v-model="newTodo"cập nhật local ref. - User nhấn Enter/Add →
handleSubmit()gọiaddTodo(title)tronguseTodos. addTodopush objectTodomới vàotodos(useState array).filteredTodos(computed) tự động tính lại → TodoList re-render.watch(todos, ...)phát hiện thay đổi → tự động save vàolocalStorage.- User click checkbox toggle → TodoItem emit
toggle(id)→toggleTodo(id)trong useTodos. - User double-click todo → TodoItem vào edit mode →
saveEdit()emitedit(id, title). - User click filter → TodoFilter cập nhật
filterquadefineModel→filteredTodosre-compute. - User click Clear completed →
clearCompleted()xóa todos đã hoàn thành.
Tính năng Nuxt 4 được áp dụng
| Tính năng | File | Giải thích |
|---|---|---|
| Auto-import | Tất cả | ref, computed, watch, useState, useHead, useSeoMeta — không cần import |
| File-based routing | pages/index.vue | Tự động trở thành route / |
| Components auto-import | TodoForm, TodoList, TodoItem, TodoFilter | Dùng trực tiếp trong template không cần import |
| Composables auto-import | useTodos | Export named function → auto-available ở mọi component |
| useState | useTodos.ts | Shared state SSR-safe |
| TypeScript | types/todo.ts | Type safety full-stack |
| useHead / useSeoMeta | pages/index.vue | SEO built-in, SSR + client |
| Scoped CSS | Tất cả component | Style không leak |
| TransitionGroup | TodoList.vue | Animation thêm/xóa item |
| defineModel | TodoFilter.vue | Vue 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:
- Form Add ở đầu trang — gõ text vào và nhấn Enter hoặc click Add.
- Todo item xuất hiện bên dưới — click checkbox để toggle completed.
- Filter buttons (All / Active / Completed) — lọc danh sách todo.
- Double-click vào todo title để edit — Enter/Escape để save/hủy.
- Hover vào dòng todo để thấy nút xóa (dấu ×).
- Footer hiển thị số lượng active items + Clear completed.
- Reload page — todo vẫn còn vì đã save vào localStorage.
- Animation — thêm/xóa todo có transition mượt mà.
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.