BÀI 13: CRUD TODO APP – PINIA + API

jk5587725

By jk5587725

Đăng ngày Tháng 7 7, 2025

Todo App hoàn chỉnh dùng Pinia + API là bài tổng hợp cực kỳ thực chiến, kết hợp tất cả những gì bạn đã học:

  • Vue 3 Composition API
  • Component
  • Pinia (State management)
  • Fetch API
  • Router
  • CRUD (Create, Read, Update, Delete)

Mục tiêu:

  • Tạo ứng dụng Todo App hoàn chỉnh
  • Kết nối API giả lập (jsonplaceholder)
  • Lưu dữ liệu todo vào Pinia (global state)
  • CRUD: Thêm, sửa, xóa công việc

Chuẩn bị

API sử dụng:

📁 Cấu trúc:

index.html
├── Vue 3 CDN
├── Vue Router CDN
├── Pinia CDN
├── App (TodoApp)
│   ├── TodoList
│   ├── AddTodo
│   └── store/todoStore

Demo: Full App Trong Một File (Chạy trực tiếp)

<!DOCTYPE html>
<html lang="vi">
<head>
  <meta charset="UTF-8">
  <title>Todo App với Pinia + API</title>
  <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
  <script src="https://unpkg.com/vue-router@4"></script>
  <script src="https://unpkg.com/pinia@2/dist/pinia.iife.js"></script>
  <style>
    body { font-family: Arial; padding: 20px; max-width: 800px; margin: auto; }
    nav a { margin-right: 15px; text-decoration: none; color: blue; }
    nav a.router-link-exact-active { font-weight: bold; color: darkgreen; }
    .todo { padding: 10px; border: 1px solid #ccc; margin-bottom: 8px; border-radius: 5px; }
    .done { text-decoration: line-through; color: gray; }
    button { margin-left: 10px; }
    input[type="text"] { width: 300px; padding: 5px; }
  </style>
</head>
<body>

<div id="app">
  <h2>✅ TODO APP + PINIA + API</h2>
  <nav>
    <router-link to="/">📋 Danh sách</router-link>
    <router-link to="/add">➕ Thêm</router-link>
  </nav>
  <router-view></router-view>
</div>

<script>
// Import từ CDN
const { createApp, ref, computed, onMounted } = Vue
const { createRouter, createWebHashHistory } = VueRouter
const { createPinia, defineStore } = Pinia

// 🔧 Store quản lý todo
const useTodoStore = defineStore('todo', {
  state: () => ({
    todos: [],
    loading: false,
    error: null
  }),
  actions: {
    async fetchTodos() {
      this.loading = true
      try {
        const res = await fetch('https://jsonplaceholder.typicode.com/todos?_limit=5')
        this.todos = await res.json()
      } catch (e) {
        this.error = 'Không tải được dữ liệu'
      } finally {
        this.loading = false
      }
    },
    async addTodo(text) {
      const res = await fetch('https://jsonplaceholder.typicode.com/todos', {
        method: 'POST',
        body: JSON.stringify({ title: text, completed: false }),
        headers: { 'Content-Type': 'application/json' }
      })
      const newTodo = await res.json()
      this.todos.unshift(newTodo)
    },
    async deleteTodo(id) {
      await fetch(`https://jsonplaceholder.typicode.com/todos/${id}`, {
        method: 'DELETE'
      })
      this.todos = this.todos.filter(t => t.id !== id)
    },
    async toggleTodo(id) {
      const todo = this.todos.find(t => t.id === id)
      const res = await fetch(`https://jsonplaceholder.typicode.com/todos/${id}`, {
        method: 'PUT',
        body: JSON.stringify({ ...todo, completed: !todo.completed }),
        headers: { 'Content-Type': 'application/json' }
      })
      const updated = await res.json()
      todo.completed = updated.completed
    }
  }
})

// 🧩 Component: Danh sách
const TodoList = {
  setup() {
    const todoStore = useTodoStore()
    onMounted(() => todoStore.fetchTodos())

    return { 
      todos: computed(() => todoStore.todos),
      loading: computed(() => todoStore.loading),
      error: computed(() => todoStore.error),
      toggle: todoStore.toggleTodo,
      del: todoStore.deleteTodo
    }
  },
  template: `
    <div>
      <p v-if="loading">⏳ Đang tải...</p>
      <p v-if="error" style="color:red;">❌ {{ error }}</p>

      <div v-for="todo in todos" :key="todo.id" class="todo" :class="{done: todo.completed}">
        <input type="checkbox" :checked="todo.completed" @change="toggle(todo.id)">
        {{ todo.title }}
        <button @click="del(todo.id)">❌</button>
      </div>
    </div>
  `
}

// 🧩 Component: Thêm mới
const AddTodo = {
  setup() {
    const todoStore = useTodoStore()
    const newText = ref('')

    const add = async () => {
      if (newText.value.trim()) {
        await todoStore.addTodo(newText.value.trim())
        newText.value = ''
      }
    }

    return { newText, add }
  },
  template: `
    <div>
      <h3>➕ Thêm công việc</h3>
      <input type="text" v-model="newText" placeholder="Nhập nội dung...">
      <button @click="add">Thêm</button>
    </div>
  `
}

// 🚦 Router config
const routes = [
  { path: '/', component: TodoList },
  { path: '/add', component: AddTodo }
]

const router = createRouter({
  history: createWebHashHistory(),
  routes
})

// 🧠 App chính
const app = createApp({})
const pinia = createPinia()
app.use(pinia)
app.use(router)
app.mount('#app')
</script>

</body>
</html>

Bạn đã học được:

Kỹ năngÁp dụng
PiniaQuản lý todo toàn cục
fetch APIGiao tiếp với backend
CRUDThêm / sửa / xóa công việc
Composition APICode sạch, dễ mở rộng
RouterTách trang: danh sách / thêm mới

Bài tập mở rộng:

  1. Thêm chức năng chỉnh sửa todo
  2. Thêm bộ lọc: Tất cả / Đã làm / Chưa làm
  3. Lưu localStorage khi không dùng API

📂 Chuyên mục:

Thảo luận

Email của bạn sẽ không được hiển thị công khai. Các trường bắt buộc được đánh dấu *

Đăng ký nhận tin mới

Nhận bài học, tài nguyên và cơ hội việc làm qua email hàng tuần.

Chúng tôi cam kết không spam. Bạn có thể hủy bất cứ lúc nào.