Đặt vấn đề: Cuộc chiến giữa Client-State và Server-State

Trong các ứng dụng dashboard thương mại điện tử quy mô lớn, việc quản lý trạng thái (state) luôn là một trong những thử thách lớn nhất đối với các kỹ sư Frontend. Khi ứng dụng phình to, chúng ta thường đối mặt với hai loại dữ liệu song song tồn tại: Client-State (trạng thái giao diện như đóng/mở sidebar, bộ lọc tìm kiếm, các dòng được chọn trong bảng) và Server-State (dữ liệu thực tế từ cơ sở dữ liệu như danh sách đơn hàng, doanh thu, thông tin sản phẩm).

Một sai lầm phổ biến của nhiều lập trình viên là cố gắng đưa toàn bộ dữ liệu tải về từ API vào một global state manager như Redux hoặc Zustand để quản lý tập trung. Cách tiếp cận này vô tình biến Client-State thành một bản sao lỗi thời của Server-State, dẫn đến hàng loạt bug nghiêm trọng như bất đồng bộ dữ liệu, race conditions (xung đột tiến trình), và hiệu năng suy giảm do re-render vô tội vạ.

Bài viết này sẽ đi sâu vào kỹ thuật phân tách và đồng bộ hóa tối ưu giữa Zustand (đại diện cho Client-State) và Tanstack Query (đại diện cho Server-State) để xây dựng một hệ thống Dashboard mượt mà, nhất quán và có khả năng mở rộng cao.

Kiến trúc Phân định Ranh giới State (State Separation Architecture)

Để giải quyết triệt để bài toán đồng bộ, trước hết chúng ta cần định nghĩa rõ ràng ranh giới trách nhiệm của từng công cụ:

  • Tanstack Query (React Query): Chịu trách nhiệm hoàn toàn về Server-State. Nó đảm nhận việc caching, refetching khi window focus, quản lý trạng thái loading/error, và tự động cập nhật dữ liệu ngầm (background updates).
  • Zustand: Chịu trách nhiệm hoàn toàn về Client-State. Nó là một thư viện siêu nhẹ giúp quản lý các trạng thái tương tác của người dùng mà không cần liên lạc trực tiếp với database (ví dụ: danh sách các ID đơn hàng đang được chọn để xử lý hàng loạt, trạng thái đóng mở modal, cấu hình bộ lọc tạm thời).

Nguyên tắc cốt lõi ở đây là: Không bao giờ lưu trữ dữ liệu API trực tiếp vào Zustand store. Thay vào đó, chúng ta chỉ lưu trữ các tham số điều hướng (query parameters) hoặc các trạng thái tương tác trong Zustand, sau đó dùng chúng làm queryKey để Tanstack Query tự động fetch và cache dữ liệu tương ứng.

Chống lại Anti-Pattern: Tại sao không nên dùng useEffect để đồng bộ?

Nhiều lập trình viên thường viết code theo mô hình phản xạ: khi dữ liệu từ Tanstack Query thay đổi, họ dùng useEffect để cập nhật dữ liệu đó vào Zustand store. Đây là một anti-pattern cực kỳ nguy hiểm vì:

  1. Gây ra hiện tượng Double Render: Component sẽ render lần đầu khi Query nhận dữ liệu mới, sau đó useEffect chạy và cập nhật Zustand, kích hoạt lần render thứ hai.
  2. Dễ tạo vòng lặp vô hạn (Infinite Loops): Nếu logic trong Zustand vô tình kích hoạt lại một hành động fetch dữ liệu, ứng dụng sẽ rơi vào vòng lặp vô tận.
  3. Mất đi tính Single Source of Truth (SSOT): Dữ liệu lúc này nằm ở cả hai nơi, việc debug nguồn gốc của một bug hiển thị sẽ trở nên cực kỳ khó khăn.

Giải pháp Thực tiễn: Xây dựng Hệ thống Quản lý Đơn hàng (Order Management)

Hãy cùng hiện thực hóa giải pháp kiến trúc này thông qua một ví dụ thực tế: Trang quản lý đơn hàng của một Dashboard Ecommerce. Tính năng yêu cầu:

  • Người dùng có thể lọc đơn hàng theo trạng thái và từ khóa tìm kiếm (lưu trong Zustand).
  • Người dùng có thể chọn nhiều đơn hàng cùng lúc để thực hiện thao tác xử lý hàng loạt (Bulk Action).
  • Khi thực hiện Bulk Action thành công (ví dụ: cập nhật trạng thái đơn hàng loạt), hệ thống phải refetch dữ liệu mới từ server và tự động xóa sạch danh sách các đơn hàng đã chọn trong Zustand.

Bước 1: Thiết kế Zustand Store cho Client-State

Chúng ta định nghĩa store chỉ chứa các trạng thái tương tác của UI như bộ lọc và danh sách ID được chọn.

import { create } from 'zustand';

interface OrderUIState {
  selectedOrderIds: string[];
  filters: {
    status: string;
    search: string;
  };
  toggleSelectOrder: (id: string) => void;
  clearSelection: () => void;
  setFilter: (key: 'status' | 'search', value: string) => void;
}

export const useOrderUIStore = create<OrderUIState>((set) => ({
  selectedOrderIds: [],
  filters: {
    status: 'all',
    search: '',
  },
  toggleSelectOrder: (id) => set((state) => ({
    selectedOrderIds: state.selectedOrderIds.includes(id)
      ? state.selectedOrderIds.filter((item) => item !== id)
      : [...state.selectedOrderIds, id]
  })),
  clearSelection: () => set({ selectedOrderIds: [] }),
  setFilter: (key, value) => set((state) => ({
    filters: {
      ...state.filters,
      [key]: value
    }
  }))
}));

Bước 2: Kết hợp Tanstack Query để quản lý Server-State

Tại đây, chúng ta sẽ viết một custom hook để kết nối hai thế giới lại với nhau. Điểm mấu chốt là việc sử dụng filters từ Zustand store làm một phần của queryKey trong Tanstack Query. Khi bộ lọc thay đổi, Tanstack Query sẽ tự động kích hoạt lượt fetch mới.

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useOrderUIStore } from './useOrderUIStore';

// Giả lập API call
const fetchOrders = async (filters: { status: string; search: string }) => {
  const response = await fetch(`/api/orders?status=${filters.status}&search=${filters.search}`);
  if (!response.ok) throw new Error('Failed to fetch orders');
  return response.json();
};

const updateOrdersStatus = async (orderIds: string[], status: string) => {
  const response = await fetch('/api/orders/bulk-status', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ orderIds, status }),
  });
  if (!response.ok) throw new Error('Failed to update orders');
  return response.json();
};

export const useOrdersDashboard = () => {
  const queryClient = useQueryClient();
  
  // Lấy Client-State từ Zustand
  const filters = useOrderUIStore((state) => state.filters);
  const selectedOrderIds = useOrderUIStore((state) => state.selectedOrderIds);
  const clearSelection = useOrderUIStore((state) => state.clearSelection);

  // Query tự động chạy lại khi filters thay đổi
  const { data: orders = [], isLoading, error } = useQuery({
    queryKey: ['orders', filters],
    queryFn: () => fetchOrders(filters),
    placeholderData: (keepPreviousData) => keepPreviousData,
  });

  // Mutation xử lý cập nhật trạng thái hàng loạt
  const bulkUpdateMutation = useMutation({
    mutationFn: (status: string) => updateOrdersStatus(selectedOrderIds, status),
    onSuccess: () => {
      // 1. Làm mới dữ liệu trên Server-State
      queryClient.invalidateQueries({ queryKey: ['orders'] });
      
      // 2. Đồng bộ ngược về Client-State: Xóa các mục đã chọn sau khi xử lý thành công
      clearSelection();
    },
    onError: (err) => {
      console.error('Bulk update failed:', err);
    }
  });

  return {
    orders,
    isLoading,
    error,
    selectedOrderIds,
    bulkUpdateMutation
  };
};

Phân tích Cơ chế Đồng bộ hóa Ngược (Reverse Synchronization)

Trong đoạn mã trên, hãy chú ý vào hàm callback onSuccess của bulkUpdateMutation. Đây chính là nơi diễn ra quá trình đồng bộ hóa ngược cực kỳ an toàn và hiệu quả:

  1. Khi người dùng click nút "Hoàn thành hàng loạt", mutation sẽ gửi danh sách ID lên server.
  2. Sau khi server xử lý thành công và trả về phản hồi, queryClient.invalidateQueries được gọi để đánh dấu cache cũ là stale. Tanstack Query sẽ tự động fetch lại dữ liệu mới nhất dưới nền.
  3. Ngay sau đó, hành động clearSelection() từ Zustand store được kích hoạt để đưa mảng selectedOrderIds về rỗng.

Toàn bộ luồng dữ liệu này đi theo một chiều duy nhất (Unidirectional Data Flow), giúp loại bỏ hoàn toàn nguy cơ race conditions và đảm bảo giao diện người dùng luôn phản ánh chính xác trạng thái thực tế của hệ thống.

Tối ưu hóa Hiệu năng hiển thị với Selectors

Một vấn đề thường gặp khi sử dụng Zustand là việc re-render không mong muốn khi các phần khác của store thay đổi. Để tối ưu hóa, hãy luôn sử dụng các selector cụ thể thay vì lấy toàn bộ store.

Ví dụ, thay vì viết:

const store = useOrderUIStore(); // Component sẽ re-render khi BẤT KỲ state nào thay đổi

Hãy viết:

const filters = useOrderUIStore((state) => state.filters); // Chỉ re-render khi filters thay đổi

Bằng cách này, component danh sách đơn hàng sẽ không bị re-render khi người dùng tích chọn một đơn hàng cụ thể (chỉ làm thay đổi selectedOrderIds chứ không làm thay đổi filters).

Kết luận

Việc phân định rạch ròi giữa Client-State và Server-State không chỉ giúp mã nguồn của bạn trở nên sạch sẽ, dễ bảo trì mà còn tối ưu hóa hiệu năng render của ứng dụng React một cách đáng kinh ngạc. Zustand và Tanstack Query khi được kết hợp đúng cách sẽ tạo nên một cặp bài trùng mạnh mẽ cho bất kỳ dự án Dashboard phức tạp nào.

Để làm chủ các kỹ thuật tối ưu hóa này và thực hành xây dựng một hệ thống quản lý hoàn chỉnh từ Front-End đến Back-End với các công nghệ hiện đại nhất, bạn có thể tham khảo khóa học thực chiến chất lượng cao. Tham khảo khóa học "[Full Course] Ecommerce Dashboard Fullstack Clone" tại đây.