Product Listing Performance — Khi catalog 10,000 SKU làm chết trang
Render 10,000 sản phẩm cùng lúc là công thức để giết browser. Bài này phân tích virtualization, infinite scroll, URL-driven filter và cách tránh layout shift khi filter thay đổi.
Product Listing Performance — Khi catalog 10,000 SKU làm chết trang
Một trang danh sách sản phẩm với 10,000 item render cùng lúc sẽ tạo ra hàng chục nghìn DOM nodes. Browser sẽ mất vài giây chỉ để layout, chưa kể scroll performance tệ hại. Đây là cách xử lý đúng.
Vấn đề 1: Virtualization với react-window
Thay vì render tất cả items, chỉ render những gì đang visible trong viewport.
import { FixedSizeGrid } from "react-window";
import AutoSizer from "react-virtualized-auto-sizer";
const COLUMN_COUNT = 4;
const ROW_HEIGHT = 320; // chiều cao mỗi card
function ProductGrid({ products }: { products: Product[] }) {
const rowCount = Math.ceil(products.length / COLUMN_COUNT);
const Cell = ({ columnIndex, rowIndex, style }: GridChildComponentProps) => {
const index = rowIndex * COLUMN_COUNT + columnIndex;
const product = products[index];
if (!product) return <div style={style} />;
return (
<div style={style} className="p-2">
<ProductCard product={product} />
</div>
);
};
return (
<AutoSizer>
{({ width, height }) => (
<FixedSizeGrid
columnCount={COLUMN_COUNT}
columnWidth={width / COLUMN_COUNT}
height={height}
rowCount={rowCount}
rowHeight={ROW_HEIGHT}
width={width}
overscanRowCount={2} // Pre-render 2 rows ngoài viewport
>
{Cell}
</FixedSizeGrid>
)}
</AutoSizer>
);
}Khi nào dùng virtualization: Danh sách > 200 items. Dưới ngưỡng đó, overhead của virtualization không đáng.
Vấn đề 2: Infinite Scroll vs Pagination
| Infinite Scroll | Pagination | |
|---|---|---|
| SEO | Khó (cần SSR từng page) | Tốt hơn |
| UX mobile | Tự nhiên hơn | Cần click |
| Deep link | Khó (user không thể share vị trí) | Dễ |
| Analytics | Phức tạp | Đơn giản |
Hybrid approach — "Load more" button thay vì auto-scroll:
function ProductList() {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteQuery({
queryKey: ["products", filters],
queryFn: ({ pageParam = 1 }) => fetchProducts({ page: pageParam, ...filters }),
getNextPageParam: (lastPage) =>
lastPage.hasMore ? lastPage.currentPage + 1 : undefined,
});
const allProducts = data?.pages.flatMap((p) => p.items) ?? [];
return (
<>
<ProductGrid products={allProducts} />
{hasNextPage && (
<button
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
className="mt-8 mx-auto block"
>
{isFetchingNextPage ? "Đang tải..." : "Xem thêm sản phẩm"}
</button>
)}
</>
);
}Vấn đề 3: URL-driven Filter State
Filter state trong component state sẽ reset khi user navigate. Lưu trong URL để shareable và back/forward work.
// hooks/useProductFilters.ts
export function useProductFilters() {
const router = useRouter();
const searchParams = useSearchParams();
const filters = useMemo(
() => ({
category: searchParams.get("category") ?? "",
minPrice: Number(searchParams.get("minPrice") ?? 0),
maxPrice: Number(searchParams.get("maxPrice") ?? 10_000_000),
sort: (searchParams.get("sort") as SortOption) ?? "relevance",
page: Number(searchParams.get("page") ?? 1),
}),
[searchParams]
);
const setFilter = useCallback(
(key: string, value: string | number) => {
const params = new URLSearchParams(searchParams.toString());
if (value) {
params.set(key, String(value));
} else {
params.delete(key);
}
params.set("page", "1"); // Reset về trang 1 khi filter thay đổi
router.push(`?${params.toString()}`, { scroll: false });
},
[router, searchParams]
);
return { filters, setFilter };
}Vấn đề 4: Skeleton Loading Strategy
// Sai: 1 skeleton cho cả trang → UX không tự nhiên
if (isLoading) return <FullPageSkeleton />;
// Đúng: skeleton từng card với số lượng ước tính
function ProductGrid({ isLoading, products }: Props) {
if (isLoading) {
return (
<div className="grid grid-cols-4 gap-4">
{Array.from({ length: 12 }).map((_, i) => (
<ProductCardSkeleton key={i} />
))}
</div>
);
}
// ...
}
function ProductCardSkeleton() {
return (
<div className="animate-pulse">
<div className="bg-gray-200 aspect-square rounded-lg" />
<div className="mt-2 h-4 bg-gray-200 rounded w-3/4" />
<div className="mt-1 h-4 bg-gray-200 rounded w-1/2" />
</div>
);
}Vấn đề 5: Tránh Layout Shift (CLS)
Khi filter thay đổi số lượng items từ 20 xuống 3, grid collapse đột ngột gây CLS cao.
function ProductGrid({ products, isLoading }: Props) {
// Giữ min-height cố định để tránh layout shift
// hoặc dùng min-height bằng chiều cao grid với N rows
const CARD_HEIGHT = 320;
const CARDS_PER_ROW = 4;
const MIN_ROWS = 3;
const minHeight = CARD_HEIGHT * MIN_ROWS;
return (
<div
className="grid grid-cols-4 gap-4"
style={{ minHeight }} // Không collapse khi ít kết quả
>
{isLoading
? Array.from({ length: 12 }).map((_, i) => <ProductCardSkeleton key={i} />)
: products.map((p) => <ProductCard key={p.id} product={p} />)}
</div>
);
}Key Takeaways
- Virtualization: dùng react-window khi list > 200 items
- Infinite scroll: prefer "Load more" button hơn auto-scroll để hỗ trợ deep link
- URL state: filter/sort/page luôn trong URL params — shareable, back/forward work
- Skeleton: số lượng skeleton card nên khớp với page size thực tế
- CLS: set
minHeightcho grid container để tránh layout jump khi filter thay đổi
If you found this helpful, leave a like!
Related Posts
Comments available after DB is connected.