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
shallowcompare từzustand/shallownếu selector trả về một object. - Áp dụng
React.memocho 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 Zustand và TanStack 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.







