Back to Notes
Performance2 phút đọcOctober 15, 2025

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.

tsx
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 ScrollPagination
SEOKhó (cần SSR từng page)Tốt hơn
UX mobileTự nhiên hơnCần click
Deep linkKhó (user không thể share vị trí)Dễ
AnalyticsPhức tạpĐơn giản

Hybrid approach — "Load more" button thay vì auto-scroll:

tsx
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.

tsx
// 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

tsx
// 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.

tsx
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 minHeight cho 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.