Vue 3 + Pinia + TypeScript: Giải bài toán State Management cho dự án thực tế

Development tutorial - IT technology blog
Development tutorial - IT technology blog

Xử lý bug giỏ hàng lúc 2 giờ sáng

Màn hình nhấp nháy, ly cà phê thứ ba đã nguội ngắt. Đó là thực tế của mình khi đối mặt với một bug “ma” trên trang thương mại điện tử: giỏ hàng bỗng dưng trống rỗng khi user chuyển trang. Sau 3 tiếng lội ngược dòng code, mình nhận ra dự án đang mắc kẹt trong cái bẫy Prop Drilling.

Dữ liệu người dùng phải đi một hành trình gian nan: từ App.vue xuống Layout, qua Header, vào Navbar rồi mới tới CartWidget. Chỉ cần một mắt xích quên xử lý emit hoặc truyền sai kiểu dữ liệu, toàn bộ logic sẽ đổ gãy như domino.

Trong dự án gần nhất với team 5 người, mình đã chuyển hướng sang dùng Pinia kết hợp TypeScript. Kết quả rất ấn tượng: tốc độ phát triển tính năng mới tăng khoảng 30%. Những lỗi vặt về dữ liệu gần như biến mất vì mọi thứ được kiểm soát chặt chẽ ngay từ lúc gõ phím.

Tại sao cách quản lý state cũ thường thất bại?

Vấn đề không nằm ở Vue 3. Nó nằm ở cách chúng ta tổ chức dữ liệu khi ứng dụng bắt đầu phình to. Ba rào cản lớn nhất bao gồm:

  • Dữ liệu phân tán: Component A thay đổi nhưng Component B không hề hay biết. Điều này dẫn đến sự không nhất quán trên giao diện.
  • Gánh nặng bảo trì: Việc truyền prop qua 4-5 lớp trung gian khiến code cực kỳ rối. Khi cần thêm một field mới, bạn phải sửa lại toàn bộ các component dọc đường truyền.
  • Rủi ro Runtime: Với JavaScript thuần, bạn không thể chắc chắn user.id có tồn tại hay không. App thường crash bất ngờ trên trình duyệt của khách hàng chỉ vì một giá trị undefined.

Nhìn lại các giải pháp trước đây

Mình đã từng thử qua nhiều phương án nhưng đều có những điểm yếu riêng:

  1. Event Bus: Một mớ hỗn độn. Trong dự án lớn, bạn sẽ không thể biết ai đang bắn event và ai đang lắng nghe.
  2. Vuex: Từng là tiêu chuẩn vàng nhưng quá rườm rà. Việc phải viết Mutations, Actions và Getter riêng biệt khiến file phình to. Đặc biệt, hỗ trợ TypeScript của Vuex rất tệ.
  3. Provide/Inject: Phù hợp cho app nhỏ. Tuy nhiên, nó thiếu các công cụ hỗ trợ quan trọng như DevTools hay khả năng Debug trạng thái theo thời gian.

Combo sức mạnh: Vue 3 + Pinia + TypeScript

Pinia hiện là lựa chọn tối ưu cho các dự án Production. Nó chỉ nặng khoảng 1.5kb (nhẹ hơn Vuex gấp nhiều lần) và có API cực kỳ gần gũi với Composition API.

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

Thay vì Vue CLI cũ kỹ, hãy dùng Vite để tận hưởng tốc độ build nhanh như chớp. Mở terminal và chạy các lệnh sau:

npm create vite@latest my-vue-app -- --template vue-ts
cd my-vue-app
npm install pinia

2. Tối ưu cấu hình Alias

Đừng để code của bạn đầy rẫy những đường dẫn ../../../../. Hãy mở vite.config.ts và thiết lập alias @ để trỏ thẳng vào thư mục src:

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'

export default defineConfig({
  plugins: [vue()],
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src'),
    },
  },
})

Lưu ý: Bạn cần cập nhật thêm compilerOptions.paths trong tsconfig.json để TypeScript nhận diện được ký tự @ này.

3. Định nghĩa Store theo phong cách hiện đại

Thay vì dùng Option API, mình khuyến khích dùng Setup Store. Cách này giúp bạn gom nhóm logic cực kỳ hiệu quả.

import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

interface UserProfile {
  id: number
  name: string
  email: string
}

export const useUserStore = defineStore('user', () => {
  const profile = ref<UserProfile | null>(null)
  const isLoading = ref(false)
  const isLoggedIn = computed(() => !!profile.value)

  async function fetchUser(id: number) {
    isLoading.value = true
    try {
      const response = await fetch(`https://api.example.com/users/${id}`)
      profile.value = await response.json()
    } catch (error) {
      console.error('API Error:', error)
    } finally {
      isLoading.value = false
    }
  }

  return { profile, isLoading, isLoggedIn, fetchUser }
})

Lợi ích lớn nhất ở đây là tính năng Auto-complete. Khi bạn gõ userStore.profile., VS Code sẽ gợi ý chính xác các field. Điều này giúp loại bỏ hoàn toàn các lỗi sai chính tả ngớ ngẩn.

4. Sử dụng Store trong Component

Việc truy xuất dữ liệu giờ đây rất gọn gàng. Bạn có thể gọi Store ở bất cứ đâu mà không cần quan tâm đến cấp bậc component.

<script setup lang="ts">
import { useUserStore } from '@/stores/user'

const userStore = useUserStore()
const loadData = () => userStore.fetchUser(1)
</script>

<template>
  <div v-if="userStore.isLoading">Đang tải...</div>
  <div v-else>
    <h1>Chào {{ userStore.profile?.name }}</h1>
    <button @click="loadData">Tải dữ liệu</button>
  </div>
</template>

Kinh nghiệm thực chiến cho môi trường Production

Để ứng dụng thực sự ổn định, chỉ cài đặt Pinia thôi là chưa đủ. Dưới đây là 3 kỹ thuật mình luôn áp dụng:

Lưu trữ trạng thái (Persistence): User sẽ rất khó chịu nếu F5 trang web mà giỏ hàng bị mất. Hãy dùng pinia-plugin-persistedstate để tự động đồng bộ dữ liệu vào LocalStorage.

Chia để trị (Modularization): Đừng tạo một store khổng lồ. Hãy chia nhỏ thành authStore, cartStore, productStore. Pinia cho phép các store này gọi lẫn nhau rất linh hoạt.

Validate dữ liệu từ API: TypeScript chỉ bảo vệ bạn lúc code. Dữ liệu thực tế từ API có thể sai lệch. Sử dụng thêm thư viện Zod để kiểm tra kiểu dữ liệu ngay khi nhận phản hồi từ server.

Lời kết

Nhìn lại những đêm thức trắng sửa bug, mình rút ra bài học: đầu tư vào kiến trúc ngay từ đầu luôn rẻ hơn đi dọn rác sau này. Pinia và TypeScript không chỉ là công cụ, chúng là bộ khung giúp dự án của bạn bền vững hơn.

Nếu bạn đang bắt đầu dự án Vue 3, hãy chọn bộ ba Vite – Pinia – TypeScript. Có thể bạn sẽ mất thêm chút thời gian định nghĩa Interface ban đầu, nhưng khi dự án mở rộng lên hàng trăm component, bạn sẽ thấy giá trị của sự kỷ luật này.

Share: