Giới thiệu

Trong các dự án dashboard thương mại điện tử, việc đồng bộ dữ liệu từ nhiều nguồn (sản phẩm, đơn hàng, khách hàng, thống kê) và phản hồi nhanh chóng trên giao diện người dùng là một thách thức lớn. Khi sử dụng React kết hợp với TypeScript, chúng ta thường gặp vấn đề về việc truyền props sâu, gây ra việc re‑render không cần thiết và làm giảm hiệu năng. Bài viết này sẽ trình bày cách thiết kế một kiến trúc quản lý trạng thái nhẹ, linh hoạt và dễ test bằng Zustand – một thư viện state management không phụ thuộc vào context – và TanStack Query – công cụ fetch và cache dữ liệu mạnh mẽ. Các ví dụ sẽ dựa trên một dashboard ecommerce giả định, đồng thời liên kết tới khóa học [Full Course] Ecommerce Dashboard Fullstack Clone ở phần kết.

Vấn đề quản lý trạng thái trong dashboard ecommerce

Độ phức tạp khi có nhiều nguồn dữ liệu

Dashboard thường hiển thị đồng thời các bảng dữ liệu: danh sách sản phẩm, chi tiết đơn hàng, biểu đồ doanh thu, và thông tin người dùng. Mỗi bảng có thể yêu cầu các API riêng biệt, và một số dữ liệu (ví dụ: danh sách sản phẩm) lại được chia sẻ giữa nhiều component. Nếu chỉ dựa vào useState hoặc useContext, chúng ta sẽ gặp:

  • Prop drilling – truyền props qua nhiều lớp component.
  • Re‑render toàn bộ cây component khi một phần dữ liệu thay đổi.
  • Khó khăn trong việc test các logic trạng thái độc lập.

Do đó, cần một giải pháp tách biệt logic trạng thái (state) ra khỏi UI, đồng thời cung cấp cơ chế cache và tự động refetch khi dữ liệu phía server thay đổi.

Zustand – một giải pháp nhẹ cho state management

Cài đặt và cấu trúc store

npm install zustand

Sau khi cài đặt, chúng ta tạo một file store.ts để định nghĩa các slice (phân đoạn) trạng thái. Dưới đây là một ví dụ về store quản lý giỏ hàng và trạng thái UI chung:

<pre class="brush:jscript;">
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';

interface CartItem {
  productId: number;
  quantity: number;
}

interface UIState {
  darkMode: boolean;
  toggleDarkMode: () => void;
}

interface CartState {
  items: CartItem[];
  addItem: (productId: number, quantity?: number) => void;
  removeItem: (productId: number) => void;
  clearCart: () => void;
}

export const useStore = create<UIState & CartState>(
  devtools(
    persist(
      (set, get) => ({
        // UI slice
        darkMode: false,
        toggleDarkMode: () => set(state => ({ darkMode: !state.darkMode })),
        // Cart slice
        items: [],
        addItem: (productId, quantity = 1) => {
          const existing = get().items.find(i => i.productId === productId);
          if (existing) {
            set(state => ({
              items: state.items.map(i =>
                i.productId === productId ? { ...i, quantity: i.quantity + quantity } : i
              ),
            }));
          } else {
            set(state => ({ items: [...state.items, { productId, quantity }] }));
          }
        },
        removeItem: productId => {
          set(state => ({ items: state.items.filter(i => i.productId !== productId) }));
        },
        clearCart: () => set({ items: [] }),
      }),
      { name: 'ecom-dashboard-store' }
    )
  )
);
</pre>

Ở ví dụ trên, chúng ta sử dụng middleware devtools để tích hợp với Redux DevTools và persist để lưu trữ trạng thái trong localStorage. Điều này giúp người dùng duy trì giỏ hàng ngay khi tải lại trang.

Kết hợp Zustand với TanStack Query

TanStack Query (trước đây là React Query) chịu trách nhiệm fetch dữ liệu, cache và tự động refetch khi các phụ thuộc thay đổi. Khi dữ liệu được cache, chúng ta có thể đồng bộ nó vào Zustand để chia sẻ giữa các component mà không cần gọi lại API.

<pre class="brush:jscript;">
import { useQuery, QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useStore } from './store';

const queryClient = new QueryClient();

export const ProductsProvider: React.FC = ({ children }) => {
  const setProducts = useStore(state => state.setProducts);

  const { data, isLoading, error } = useQuery(['products'], async () => {
    const res = await fetch('/api/products');
    if (!res.ok) throw new Error('Failed to fetch');
    return res.json();
  }, {
    onSuccess: data => {
      // Đồng bộ dữ liệu vào Zustand để các component khác có thể truy cập nhanh
      setProducts(data);
    },
    staleTime: 1000 * 60 * 5, // 5 phút
  });

  return (
    <QueryClientProvider client={queryClient}>
      {children}
    </QueryClientProvider>
  );
};
</pre>

Trong store, chúng ta thêm một slice products và hàm setProducts để lưu trữ danh sách sản phẩm. Khi TanStack Query trả về dữ liệu thành công, hàm onSuccess sẽ cập nhật Zustand, cho phép các component không cần gọi useQuery lặp lại.

Thực hành: Xây dựng store cho sản phẩm và giỏ hàng

Dưới đây là một ví dụ hoàn chỉnh về store bao gồm cả sản phẩm, giỏ hàng và UI state. Chúng ta sẽ sử dụng selector để tối ưu việc lấy dữ liệu từ store, tránh re‑render toàn bộ component.

<pre class="brush:jscript;">
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';

interface Product {
  id: number;
  name: string;
  price: number;
  stock: number;
}

interface StoreState {
  products: Product[];
  setProducts: (products: Product[]) => void;
  // Cart slice
  cart: Record<number, number>; // productId => quantity
  addToCart: (productId: number, qty?: number) => void;
  removeFromCart: (productId: number) => void;
  clearCart: () => void;
  // UI slice
  darkMode: boolean;
  toggleDarkMode: () => void;
}

export const useStore = create<StoreState>(devtools(persist(set => ({
  products: [],
  setProducts: products => set({ products }),
  cart: {},
  addToCart: (productId, qty = 1) =>
    set(state => ({
      cart: {
        ...state.cart,
        [productId]: (state.cart[productId] || 0) + qty,
      },
    })),
  removeFromCart: productId =>
    set(state => {
      const { [productId]: _, ...rest } = state.cart;
      return { cart: rest };
    }),
  clearCart: () => set({ cart: {} }),
  darkMode: false,
  toggleDarkMode: () => set(state => ({ darkMode: !state.darkMode })),
}), { name: 'ecom-dashboard-store' })));
</pre>

Sau khi store đã sẵn sàng, chúng ta có thể sử dụng trong component như sau:

<pre class="brush:jscript;">
import React from 'react';
import { useStore } from '../store';

export const ProductList: React.FC = () => {
  const products = useStore(state => state.products);
  const addToCart = useStore(state => state.addToCart);

  return (
    <div>
      {products.map(p => (
        <div key={p.id} className="p-4 border-b">
          <h3>{p.name}</h3>
          <p>Giá: {p.price.toLocaleString()} VND</p>
          <button onClick={() => addToCart(p.id)}>Thêm vào giỏ</button>
        </div>
      ))}
    </div>
  );
};
</pre>

Nhờ việc sử dụng selector (state => state.products), chỉ component ProductList sẽ re‑render khi danh sách sản phẩm thay đổi, còn các component khác không bị ảnh hưởng.

Chiến lược tối ưu hiệu năng

Memoization và selector

Trong Zustand, mỗi lần gọi useStore sẽ trả về một hàm selector. Khi selector trả về giá trị mới, component sẽ re‑render. Để giảm số lần re‑render, chúng ta nên:

  • Sử dụng shallow compare từ zustand/shallow nếu selector trả về một object.
  • Áp dụng React.memo cho component nhận props phức tạp.
  • Tránh lưu trữ các giá trị tính toán trong store; thay vào đó, tính toán trong component và memoize.

Lazy loading và suspense

TanStack Query hỗ trợ Suspense để trì hoãn render cho tới khi dữ liệu sẵn sàng. Khi kết hợp với Zustand, chúng ta có thể lazy‑load các module UI (ví dụ: biểu đồ thống kê) chỉ khi người dùng mở tab tương ứng.

<pre class="brush:jscript;">
const Statistics = React.lazy(() => import('./Statistics'));

export const Dashboard: React.FC = () => (
  <React.Suspense fallback={<div>Loading...</div>}>
    <Statistics />
  </React.Suspense>
);
</pre>

Kiểm thử store với Jest và React Testing Library

Với Zustand, chúng ta có thể kiểm thử logic store một cách độc lập mà không cần mount component. Dưới đây là một ví dụ kiểm thử các hành vi của giỏ hàng.

<pre class="brush:jscript;">
import { renderHook, act } from '@testing-library/react-hooks';
import { useStore } from '../store';

describe('Cart store', () => {
  it('should add item to cart', () => {
    const { result } = renderHook(() => useStore());
    act(() => {
      result.current.addToCart(1, 2);
    });
    expect(result.current.cart[1]).toBe(2);
  });

  it('should remove item from cart', () => {
    const { result } = renderHook(() => useStore());
    act(() => {
      result.current.addToCart(2, 1);
      result.current.removeFromCart(2);
    });
    expect(result.current.cart[2]).toBeUndefined();
  });
});
</pre>

Kiểm thử này không phụ thuộc vào DOM, giúp tốc độ chạy nhanh và dễ bảo trì.

Triển khai trong môi trường Docker

Dockerfile multi‑stage cho front‑end

Để giảm kích thước image, chúng ta sử dụng multi‑stage build. Giai đoạn đầu build React app bằng node, giai đoạn thứ hai chỉ copy các file tĩnh vào nginx.

<pre class="brush:bash;">
# Stage 1: Build React app
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile
COPY . .
RUN yarn build

# Stage 2: Serve with Nginx
FROM nginx:alpine
COPY --from=builder /app/build /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
</pre>

Docker Compose cho toàn bộ stack

Dashboard thường bao gồm front‑end, back‑end (Express), database MySQL và Redis cho cache. Dưới đây là file docker-compose.yml mẫu.

<pre class="brush:bash;">
version: '3.8'
services:
  frontend:
    build: ./frontend
    ports:
      - "3000:80"
    depends_on:
      - backend

  backend:
    build: ./backend
    ports:
      - "4000:4000"
    environment:
      - DATABASE_URL=mysql://user:pass@db:3306/ecom
      - REDIS_URL=redis://redis:6379
    depends_on:
      - db
      - redis

  db:
    image: mysql:8.0
    environment:
      MYSQL_ROOT_PASSWORD: rootpass
      MYSQL_DATABASE: ecom
      MYSQL_USER: user
      MYSQL_PASSWORD: pass
    volumes:
      - db_data:/var/lib/mysql
    ports:
      - "3306:3306"

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"

volumes:
  db_data:
    driver: local
</pre>

Với cấu hình này, chúng ta có thể khởi động toàn bộ môi trường bằng lệnh docker compose up -d, đồng thời tận dụng Docker cache để giảm thời gian build.

Kết luận

Việc kết hợp ZustandTanStack Query mang lại một kiến trúc quản lý trạng thái nhẹ, dễ mở rộng và tối ưu hiệu năng cho các dashboard thương mại điện tử phức tạp. Bằng cách tách biệt logic trạng thái (store) và logic fetch (query), chúng ta giảm thiểu re‑render không cần thiết, cải thiện trải nghiệm người dùng và đơn giản hoá quá trình test. Ngoài ra, việc triển khai trong Docker multi‑stage và Docker Compose giúp môi trường phát triển và production đồng nhất, giảm lỗi cấu hình. Nếu bạn muốn có một dự án thực tế để áp dụng những kiến thức trên, hãy Tham khảo khóa học "[Full Course] Ecommerce Dashboard Fullstack Clone" tại đây để có nguồn code mẫu, hướng dẫn chi tiết và môi trường Docker sẵn sàng.