Thách thức trong quản lý dữ liệu đối với ứng dụng Vue 3 lớn

Khi xây dựng các ứng dụng Front-End ở quy mô Enterprise, việc quản lý trạng thái (State Management) luôn là một trong những bài toán phức tạp nhất. Các kỹ sư Front-End thường đối mặt với hai loại trạng thái riêng biệt: Client State (như trạng thái giao diện, theme, thông tin phiên làm việc người dùng, trạng thái đóng mở modal) và Server State (dữ liệu bất đồng bộ lấy từ backend, đòi hỏi cơ chế caching, revalidation, retry và sync).

Một sai lầm phổ biến của nhiều nhóm phát triển ứng dụng Vue 3 là cố gắng nhồi nhét toàn bộ Server State vào Pinia store. Điều này dẫn đến các điểm nghẽn kiến trúc:

  • Boilerplate Code quá lớn: Với mỗi API endpoint, lập trình viên phải viết action, mutation (nếu dùng Vuex) hoặc state/actions (trong Pinia) chỉ để lưu trữ trạng thái loading, error, data và timestamp.
  • Cơ chế Caching thủ công và dễ lỗi: Việc tự quản lý thời gian hết hạn dữ liệu (TTL), xóa cache (cache invalidation), và gọi lại API ngầm (background refetching) trong Pinia rất phức tạp.
  • Dữ liệu không nhất quán (Stale Data): Khi nhiều component truy cập và thay đổi cùng một nguồn dữ liệu mà không có cơ chế invalidate chuẩn xác, người dùng sẽ thấy dữ liệu cũ hoặc bị ghi đè không mong muốn.

Phân định trách nhiệm: Pinia vs TanStack Query (Vue Query)

Đơn giản hóa kiến trúc bằng cách phân tách rõ ràng vai trò của hai công cụ sẽ giúp mã nguồn sạch sẽ và dễ bảo trì hơn:

1. Pinia (Client State Manager)

Pinia nên được dùng làm nơi lưu trữ lý tưởng cho các trạng thái toàn cục thuần túy của client:

  • Thông tin người dùng đang đăng nhập (User Authentication Token / Session).
  • Cấu hình giao diện (Dark/Light mode, Sidebar collapsed/expanded).
  • Trạng thái Form nhiều bước (Multi-step form draft) trước khi submit.
  • Trạng thái bộ lọc toàn cục (Global Filter Settings).

2. TanStack Query (Server State Manager)

TanStack Query đảm nhận toàn bộ vòng đời của dữ liệu bất đồng bộ từ máy chủ:

  • Tự động caching dữ liệu dựa trên Query Keys.
  • Tự động gọi lại API khi người dùng chuyển tab (Refetch on window focus) hoặc kết nối mạng phục hồi.
  • Quản lý trạng thái isLoading, isFetching, isError, error tự động.
  • Hỗ trợ cơ chế Optimistic Updates (cập nhật UI ngay lập tức trước khi server phản hồi).
  • Hỗ trợ Pagination, Infinite Scroll và Parallel Queries một cách khai báo (declarative).

Xây dựng tầng Data Fetching chuyên sâu trong Vue 3

Dưới đây là mô hình triển khai thực tế bằng TypeScript, kết hợp TanStack Query v5 và Vue 3 Composition API.

Bước 1: Cấu hình QueryClient và Plugin

Đầu tiên, chúng ta khởi tạo QueryClient với các cấu hình tối ưu cho ứng dụng lớn:

import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query';
import type { App } from 'vue';

export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60 * 5, // Dữ liệu được coi là tươi (fresh) trong 5 phút
      gcTime: 1000 * 60 * 30,    // Giữ cache trong Garbage Collector 30 phút
      refetchOnWindowFocus: true, // Tự động refetch khi user quay lại tab
      retry: 2,                  // Thử lại 2 lần nếu request thất bại
    },
  },
});

export function setupQuery(app: App) {
  app.use(VueQueryPlugin, { queryClient });
}

Bước 2: Định nghĩa Query Key Factory

Để tránh trùng lặp hoặc gõ sai chuỗi Key làm hỏng cơ chế Caching, chúng ta thiết lập Query Key Factory Pattern:

export const productKeys = {
  all: ['products'] as const,
  lists: () => [...productKeys.all, 'list'] as const,
  list: (filters: Record<string, any>) => [...productKeys.lists(), filters] as const,
  details: () => [...productKeys.all, 'detail'] as const,
  detail: (id: string) => [...productKeys.details(), id] as const,
};

Bước 3: Viết Custom Composable Fetching với TypeScript

Chúng ta đóng gói logic lấy danh sách sản phẩm với bộ lọc trong một Custom Composable:

import { useQuery } from '@tanstack/vue-query';
import { computed, type Ref } from 'vue';
import { productKeys } from './productKeys';
import { fetchProductsApi } from '@/api/productApi';
import type { ProductFilterParams, ProductListResponse } from '@/types/product';

export function useProducts(params: Ref<ProductFilterParams>) {
  const queryKey = computed(() => productKeys.list(params.value));

  return useQuery<ProductListResponse, Error>({
    queryKey,
    queryFn: () => fetchProductsApi(params.value),
    enabled: computed(() => !!params.value.page),
    keepPreviousData: true, // Giữ lại dữ liệu cũ trong khi đang tải trang mới
  });
}

Kỹ thuật Optimistic Updates với Vue Query Mutation

Optimistic Update là kỹ thuật phản hồi ngay lập tức trên giao diện người dùng trước khi nhận được kết quả thành công từ API server, mang lại trải nghiệm mượt mà vượt trội.

Giả sử chúng ta xây dựng tính năng cập nhật số lượng tồn kho sản phẩm:

import { useMutation, useQueryClient } from '@tanstack/vue-query';
import { productKeys } from './productKeys';
import { updateProductStockApi } from '@/api/productApi';
import type { Product } from '@/types/product';

export function useUpdateStock() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: ({ id, stock }: { id: string; stock: number }) => 
      updateProductStockApi(id, stock),
    
    // Gọi ngay trước khi mutation xảy ra
    onMutate: async ({ id, stock }) => {
      const detailKey = productKeys.detail(id);

      // Hủy bỏ các request đang chạy ngầm để không đè dữ liệu
      await queryClient.cancelQueries({ queryKey: detailKey });

      // Lưu snapshot dữ liệu cũ để rollback nếu có lỗi
      const previousProduct = queryClient.getQueryData<Product>(detailKey);

      // Cập nhật Optimistic vào Cache ngay lập tức
      if (previousProduct) {
        queryClient.setQueryData<Product>(detailKey, {
          ...previousProduct,
          stock,
        });
      }

      return { previousProduct, detailKey };
    },

    // Nếu API bị lỗi, khôi phục lại dữ liệu cũ từ Snapshot
    onError: (err, newVariables, context) => {
      if (context?.previousProduct) {
        queryClient.setQueryData(context.detailKey, context.previousProduct);
      }
    },

    // Luôn luôn invalidate query để sync lại dữ liệu chuẩn từ Server
    onSettled: (data, error, variables) => {
      queryClient.invalidateQueries({ queryKey: productKeys.detail(variables.id) });
      queryClient.invalidateQueries({ queryKey: productKeys.lists() });
    },
  });
}

Tích hợp Pinia Store cho Client State trong Component

Bây giờ hãy xem cách một Vue Component phối hợp nhịp nhàng giữa Pinia (lưu trạng thái filter của client) và TanStack Query (fetch dữ liệu sản phẩm):

<script setup lang="ts">
import { storeToRefs } from 'pinia';
import { useFilterStore } from '@/stores/filterStore';
import { useProducts } from '@/composables/useProducts';
import { useUpdateStock } from '@/composables/useUpdateStock';

// Pinia Store quản lý UI State
const filterStore = useFilterStore();
const { filterParams } = storeToRefs(filterStore);

// TanStack Query quản lý Server State
const { data, isLoading, isError, error } = useProducts(filterParams);
const { mutate: updateStock, isPending: isUpdating } = useUpdateStock();

function handleStockChange(productId: string, newStock: number) {
  updateStock({ id: productId, stock: newStock });
}
</script>

<template>
  <div class="product-container">
    <div v-if="isLoading" class="loading-spinner">
      Đang tải danh sách sản phẩm...
    </div>

    <div v-else-if="isError" class="error-box">
      Đã có lỗi xảy ra: {{ error?.message }}
    </div>

    <div v-else-if="data" class="product-grid">
      <div v-for="product in data.items" :key="product.id" class="product-card">
        <h3>{{ product.name }}</h3>
        <p>Tồn kho: {{ product.stock }}</p>
        <button 
          :disabled="isUpdating" 
          @click="handleStockChange(product.id, product.stock + 1)">
          Tăng tồn kho
        </button>
      </div>
    </div>
  </div>
</template>

Các Best Practices khi kiến trúc Data Layer trong Vue 3

  1. Tuyệt đối không gán dữ liệu từ TanStack Query vào Pinia state: Tránh hành vi lấy dữ liệu từ useQuery rồi nạp lại vào piniaStore.products = data.value. Điều này làm nhân đôi bộ nhớ và mất đi khả năng tự động revalidate của Query Engine.
  2. Bật StaleTime hợp lý: Mặc định staleTime của Vue Query là 0. Hãy điều chỉnh tùy từng loại dữ liệu. Danh sách danh mục sản phẩm ít thay đổi có thể đặt staleTime: 1000 * 60 * 60 (1 giờ), trong khi số dư ví tiền có thể đặt staleTime: 0.
  3. Tận dụng Prefetching: Khi người dùng rê chuột (hover) vào link chi tiết sản phẩm hoặc chuyển hướng phân trang, hãy chủ động gọi queryClient.prefetchQuery() để dữ liệu sẵn sàng ngay trước khi component con render.

Kết luận

Sự kết hợp giữa Pinia cho Client State và TanStack Query cho Server State là mô hình kiến trúc Front-End hiện đại, giải quyết triệt để vấn đề phình to mã nguồn, tối ưu hóa Caching và mang đến trải nghiệm người dùng tuyệt vời trong Vue 3. Khi làm chủ được tư duy phân tách state này, ứng dụng của bạn sẽ vô cùng bền vững và dễ mở rộng.

Để thành thạo toàn bộ các kỹ thuật chuyên sâu này cùng với TypeScript và Tailwind CSS trong các dự án thực tế, bạn có thể tham gia lộ trình đào tạo bài bản. Tham khảo khóa học "Lập trình Front-End với VueJS Framework" tại đây.