0

# Bài 15 — Sắp xếp & lọc sản phẩm (và sửa bug lớn nhất của dự án)

⬅️ Bài trước | Mục lục | Bài tiếp theo ➡️


🎯 Mục tiêu

  • Hoàn thiện sidebar: nút sắp xếp và ô lọc khoảng giá
  • Mổ xẻ 3 bug thật trong Productlist của source gốc
  • Viết lại Productlist đúng chuẩn với useMemo
  • Hiểu vì sao useRef là lựa chọn sai ở đây

Đây là bài quan trọng nhất về mặt tư duy trong toàn bộ tài liệu. Bạn sẽ học cách nhìn ra bug mà mắt thường không thấy.


💻 1. Hoàn thiện sidebar

Cập nhật frontend/app/(user)/menu/layout.js:

"use client"
import Link from "next/link";
import useSWR from "swr";
import { usePathname } from "next/navigation";
import { useDispatch, useSelector } from "react-redux";
import { sortASC, sortDefault, sortDESC } from "@/redux/slices/sortSlide";
import { setMax, setMin } from "@/redux/slices/filterSlicer";
import { fetcher, API } from "@/lib/fetcher";

export default function Layout({ children }) {
  const { data: category, error, isLoading } = useSWR(`${API}/categories`, fetcher);
  const pathname = usePathname();
  const dispatch = useDispatch();
  const sortType = useSelector((state) => state.sort);

  if (error) return <strong>Đã có lỗi</strong>;
  if (isLoading) return <p>Loading...</p>;

  return (
    <div className="container mt-5">
      <h2>Menu</h2>
      <div className="row">
        <div className="col-sm-3">
          {/* ===== Danh mục ===== */}
          <div className="card">
            <div className="card-header">
              <h5 className="mb-0">Danh Mục</h5>
            </div>
            <ul className="list-group list-group-flush">
              <Link
                className={`list-group-item list-group-item-action ${
                  pathname === "/menu" ? "text-bg-dark" : ""
                }`}
                href="/menu"
              >
                Tất cả
              </Link>
              {category.map((item) => (
                <Link
                  key={item._id}
                  className={`list-group-item list-group-item-action ${
                    pathname === "/menu/" + item._id ? "text-bg-dark" : ""
                  }`}
                  href={`/menu/${item._id}`}
                >
                  {item.name}
                </Link>
              ))}
            </ul>
          </div>

          {/* ===== Sắp xếp ===== */}
          <div className="card mt-3">
            <div className="card-header">
              <h5 className="mb-0">Sắp Xếp</h5>
            </div>
            <div className="card-body">
              <div className="input-group">
                <button
                  className={`btn btn-${sortType === null ? "" : "outline-"}dark`}
                  onClick={() => dispatch(sortDefault())}
                >
                  Mặc Định
                </button>
                <button
                  className={`btn btn-${sortType === "ASC" ? "" : "outline-"}dark`}
                  onClick={() => dispatch(sortASC())}
                >
                  Giá tăng
                </button>
                <button
                  className={`btn btn-${sortType === "DESC" ? "" : "outline-"}dark`}
                  onClick={() => dispatch(sortDESC())}
                >
                  Giá giảm
                </button>
              </div>
            </div>
          </div>

          {/* ===== Khoảng giá ===== */}
          <div className="card mt-3">
            <div className="card-header">
              <h5 className="mb-0">Khoảng Giá</h5>
            </div>
            <div className="card-body">
              <div className="input-group">
                <div className="input-group-text">từ</div>
                <input
                  className="form-control"
                  type="number"
                  min={0}
                  step={500000}
                  defaultValue={0}
                  onChange={(e) => dispatch(setMin(e.target.value))}
                />
                <div className="input-group-text">đến</div>
                <input
                  className="form-control"
                  type="number"
                  min={0}
                  step={500000}
                  defaultValue={99999999}
                  onChange={(e) => dispatch(setMax(e.target.value))}
                />
              </div>
            </div>
          </div>
        </div>

        <div className="col-sm-9 bg-light">{children}</div>
      </div>
    </div>
  );
}

🔍 Mẹo tạo class động cho nút active

className={`btn btn-${sortType === "ASC" ? "" : "outline-"}dark`}
  • Khi đang chọn ASC → btn btn-dark (nền đen, nổi bật)
  • Khi không chọn → btn btn-outline-dark (chỉ viền)

⚠️ Lỗi giá trị mặc định trong source gốc

{/* GỐC */}
<input min={10000} max={100000} step={5000} defaultValue={10000}
  onChange={(e) => dispatch(setMin(e.target.value))} />
<input min={10000} max={100000} step={5000} defaultValue={999999999}
  onChange={(e) => dispatch(setMax(e.target.value))} />

Ba vấn đề:

  1. max={100000} = 100 nghìn. Nhưng sản phẩm rẻ nhất trong dự án là 3.460.000đ. Người dùng dùng mũi tên tăng/giảm sẽ bị chặn ở 100k → không lọc được gì.
  2. defaultValue={999999999} vượt quá max={100000} — mâu thuẫn. Trình duyệt cảnh báo và hành vi không nhất quán.
  3. step={5000} quá nhỏ với giá tiền triệu — phải bấm hàng trăm lần.

Bản trên đã sửa: bỏ max, step={500000}, defaultValue là 0 và 99999999 (khớp với initialState của filterSlicer).


⚠️ 2. Mổ xẻ Productlist gốc — 3 bug

Đây là code gốc:

import { useSelector } from "react-redux";
import Product from "./product";
import { useRef } from "react";

export default function Productlist(props) {
    let data = props.data;
    const sortType = useSelector((state) => state.sort);
    let defaultData = useRef([...props.data]);          // ← BUG 1
    const { min, max } = useSelector((state) => state.filter);

    data = data.filter((item) => min <= item.price && item.price <= max);

    if (sortType === 'ASC') {
        data.sort((a, b) => a.price - b.price);         // ← BUG 2
    } else if (sortType === 'DESC') {
        data.sort((a, b) => b.price - a.price);
    } else {
        data = defaultData.current;                     // ← BUG 3
    }

    return (
       <div className="container">
         <div className="row row-cols-lg-4 row-cols-md-3 row-cols-ms-2">
            {data.map((item) => {
                 const { _id, name, image, price } = item;   // ← khai báo thừa
                return (
                    <div key={item._id} className="col">
                        <Product data={item}/>
                    </div>
                );
            })}
        </div>
       </div>
    );
}

🐛 BUG 1 — useRef chỉ chụp dữ liệu ở lần render đầu

let defaultData = useRef([...props.data]);

Hiểu nhầm phổ biến: "Mỗi lần render, useRef sẽ cập nhật lại giá trị."

Sự thật: useRef(giaTriBanDau) chỉ dùng giaTriBanDau ở lần render ĐẦU TIÊN. Từ lần thứ hai trở đi, tham số này bị bỏ qua hoàn toàn. Giá trị trong .current giữ nguyên cho đến khi bạn tự gán lại.

Kịch bản tái hiện bug:

1. Vào /menu               → Productlist mount, defaultData.current = [12 sản phẩm]
2. Bấm "Giá tăng"          → sort ASC, hiển thị đúng
3. Bấm danh mục "Gucci"    → chuyển sang /menu/669a978e...
                             ⚠️ Productlist KHÔNG unmount (cùng vị trí trong cây React)
                             props.data đổi thành [2 sản phẩm Gucci]
                             defaultData.current VẪN LÀ [12 sản phẩm] ← CŨ!
4. Bấm "Mặc Định"          → data = defaultData.current
                           → Hiển thị 12 SẢN PHẨM của TẤT CẢ danh mục 🐛

Người dùng đang xem danh mục Gucci mà lại thấy sản phẩm của Versace, Chloé...

Vì sao component không unmount? Vì /menu và /menu/[id] dùng chung menu/layout.js. Next.js chỉ thay children, và React thấy <Productlist> vẫn ở cùng vị trí trong cây → nó reuse component cũ, chỉ cập nhật props.

Nếu React unmount rồi mount lại, useRef sẽ khởi tạo lại và bug không xảy ra — điều này khiến bug rất khó tái hiện một cách nhất quán.


🐛 BUG 2 — .sort() biến đổi mảng gốc

data.sort((a, b) => a.price - b.price);

.sort() sắp xếp tại chỗ — nó sửa chính mảng đó, không tạo mảng mới.

Ở đây data là kết quả của .filter() (mảng mới) nên có vẻ an toàn. Nhưng .filter() chỉ tạo mảng mới ở cấp ngoài — các phần tử bên trong vẫn là cùng object với mảng gốc.

Nghiêm trọng hơn, ở nhánh else:

data = defaultData.current;

data bây giờ trỏ thẳng vào mảng trong ref. Nếu ở lần render sau sortType đổi thành ASC... thực ra không, vì filter chạy trước. Nhưng logic này rất mong manh — chỉ cần đổi thứ tự vài dòng là bạn sắp xếp trực tiếp lên mảng đang được SWR cache.

Nguyên tắc: trong React, không bao giờ biến đổi dữ liệu đến từ props hoặc từ cache của thư viện. Luôn [...data].sort().


🐛 BUG 3 — Nhánh "Mặc Định" vứt bỏ kết quả lọc

data = data.filter((item) => min <= item.price && item.price <= max);   // lọc

if (sortType === 'ASC')       { data.sort(...); }
else if (sortType === 'DESC') { data.sort(...); }
else                          { data = defaultData.current; }  // ← ghi đè, mất kết quả lọc

Kịch bản:

1. Vào /menu, kiểu sắp xếp = "Mặc Định" (null)
2. Nhập khoảng giá 3.000.000 → 4.000.000
3. Kết quả: KHÔNG có gì thay đổi 🐛

Vì nhánh else gán data = defaultData.current — vứt bỏ toàn bộ kết quả filter.

Bộ lọc chỉ hoạt động khi người dùng đã bấm "Giá tăng" hoặc "Giá giảm" trước. Đây là hành vi hoàn toàn phi logic mà người dùng không thể đoán được.


Bug phụ — sai tên class Bootstrap

<div className="row row-cols-lg-4 row-cols-md-3 row-cols-ms-2">
//                                              ↑ "ms" không tồn tại

Bootstrap có sm, md, lg, xl, xxl — không có ms. Class này bị bỏ qua → trên màn hình nhỏ, mỗi hàng chỉ 1 sản phẩm.

Đúng phải là row-cols-sm-2 hoặc row-cols-2.

Bug phụ — biến khai báo không dùng

const { _id, name, image, price } = item;   // khai báo rồi... không dùng
return <div key={item._id}><Product data={item}/></div>;

Có lẽ tác giả định dùng nhưng sau đó đổi ý. Code thừa gây nhiễu người đọc.


💻 3. Viết lại Productlist đúng chuẩn

// frontend/app/(user)/component/productlist.js — PHIÊN BẢN ĐÚNG
"use client"
import { useMemo } from "react";
import { useSelector } from "react-redux";
import Product from "./product";

export default function Productlist(props) {
  const sortType = useSelector((state) => state.sort);
  const { min, max } = useSelector((state) => state.filter);

  const rawData = props.data;

  const displayData = useMemo(() => {
    // Phòng thủ: props.data có thể là undefined khi SWR chưa tải xong
    if (!Array.isArray(rawData)) return [];

    // 1. LỌC — .filter() luôn tạo mảng mới, không đụng vào rawData
    const minValue = Number(min) || 0;
    const maxValue = Number(max) || Number.MAX_SAFE_INTEGER;
    const filtered = rawData.filter(
      (item) => item.price >= minValue && item.price <= maxValue
    );

    // 2. SẮP XẾP — dùng [...filtered] để không sửa mảng đã lọc
    if (sortType === "ASC")  return [...filtered].sort((a, b) => a.price - b.price);
    if (sortType === "DESC") return [...filtered].sort((a, b) => b.price - a.price);

    // 3. MẶC ĐỊNH — trả về kết quả đã lọc, GIỮ nguyên thứ tự từ API
    return filtered;
  }, [rawData, sortType, min, max]);

  if (displayData.length === 0) {
    return (
      <p className="text-center text-muted my-5">
        Không tìm thấy sản phẩm nào phù hợp
      </p>
    );
  }

  return (
    <div className="container">
      <div className="row row-cols-lg-4 row-cols-md-3 row-cols-sm-2 row-cols-1 g-3">
        {displayData.map((item) => (
          <div key={item._id} className="col">
            <Product data={item} />
          </div>
        ))}
      </div>
    </div>
  );
}

🔍 Vì sao bản này đúng?

Bug gốc Cách sửa
1. useRef giữ dữ liệu cũ Bỏ hẳn useRef. Nhánh mặc định trả về filtered — luôn tính từ props.data mới nhất
2. .sort() sửa mảng gốc [...filtered].sort(...) — sắp xếp trên bản sao
3. Mặc định bỏ qua filter Cả 3 nhánh đều xuất phát từ filtered
4. row-cols-ms-2 row-cols-sm-2
5. Biến thừa Đã xóa

useMemo ở đây có ý nghĩa gì?

const displayData = useMemo(() => { /* lọc + sắp xếp */ },
  [rawData, sortType, min, max]);

Chỉ tính lại khi một trong 4 giá trị đó đổi. Nếu component render lại vì lý do khác (VD: giỏ hàng thay đổi → navbar render → không ảnh hưởng), phép lọc/sắp xếp không chạy lại.

Với 12 sản phẩm thì tiết kiệm không đáng kể. Nhưng với 1.000 sản phẩm, useMemo tạo khác biệt rõ rệt về độ mượt.

💡 Bài học tổng quát: "Dẫn xuất state" (derived state) — dữ liệu tính được từ dữ liệu khác — không nên lưu vào state hay ref. Hãy tính lại mỗi lần render (bọc trong useMemo nếu tốn kém).

Việc lưu dữ liệu dẫn xuất là nguồn gốc của rất nhiều bug "dữ liệu không đồng bộ", và bug 1 ở trên là ví dụ hoàn hảo.


📚 4. Vì sao lọc/sắp xếp ở client là giải pháp tạm?

Hiện tại toàn bộ 12 sản phẩm được tải về rồi mới lọc bằng JavaScript.

Client-side (hiện tại) Server-side (chuẩn)
Với 12 sản phẩm ✅ Nhanh, đơn giản Thừa thãi
Với 10.000 sản phẩm ❌ Tải 50MB JSON, trình duyệt lag ✅ Chỉ tải 20 sản phẩm/trang
Phân trang ❌ Khó làm đúng ✅ Tự nhiên
Chia sẻ link kết quả lọc ❌ Không được (state trong Redux) ✅ URL chứa query param
SEO ❌ Google không thấy kết quả lọc ✅ Mỗi bộ lọc là 1 URL

Bài 21 sẽ chuyển sang server-side với API:

GET /products?minPrice=3000000&maxPrice=5000000&sort=price_asc&page=1&limit=12

✅ 5. Kiểm thử

Vào http://localhost:3000/menu và làm theo đúng thứ tự để xác nhận đã hết bug:

Test bug 3 — lọc khi đang ở chế độ Mặc Định

  1. Đảm bảo nút "Mặc Định" đang được chọn (nền đen)
  2. Nhập khoảng giá: từ 3000000 đến 4000000
  3. ✅ Danh sách phải thu hẹp lại ngay ❌ Với bản gốc: không có gì thay đổi

Test bug 1 — chuyển danh mục rồi bấm Mặc Định

  1. Bấm "Giá tăng"
  2. Bấm danh mục "Gucci" ở sidebar
  3. Bấm "Mặc Định"
  4. ✅ Vẫn chỉ hiện sản phẩm Gucci ❌ Với bản gốc: hiện lại toàn bộ 12 sản phẩm

Test bug 2 — sắp xếp không làm hỏng dữ liệu

  1. Bấm "Giá giảm" → sản phẩm đắt nhất lên đầu
  2. Bấm "Giá tăng" → sản phẩm rẻ nhất lên đầu
  3. Bấm "Mặc Định" → trở về thứ tự ban đầu từ API
  4. ✅ Bấm qua lại nhiều lần, kết quả luôn nhất quán

Test tổng hợp

  1. Danh mục "Gucci" + khoảng giá 4tr–6tr + Giá tăng
  2. Cả 3 điều kiện phải cùng có hiệu lực

Test trường hợp rỗng

  1. Nhập khoảng giá 1 đến 2
  2. ✅ Hiện "Không tìm thấy sản phẩm nào phù hợp" ❌ Với bản gốc: lưới trống trơn, người dùng tưởng web lỗi

📝 Bài tập

  1. Nút "Xóa bộ lọc" — dispatch resetFilter() và sortDefault(), đồng thời reset giá trị 2 ô input về mặc định (gợi ý: dùng controlled input với state trong Redux thay vì defaultValue).

  2. Hiện số lượng kết quả: Tìm thấy 5 sản phẩm phía trên lưới.

  3. Thêm kiểu sắp xếp mới:

    • Theo tên A→Z: .sort((a,b) => a.name.localeCompare(b.name, 'vi'))
    • Theo đánh giá cao nhất: .sort((a,b) => b.rating - a.rating)
  4. Đồng bộ bộ lọc với URL: dùng useSearchParams và router.push để /menu?min=3000000&max=5000000&sort=asc. Lúc này link chia sẻ được, và F5 không mất bộ lọc.

  5. Tái hiện bug 1 để hiểu sâu: tạm thời khôi phục useRef trong Productlist, thêm dòng debug:

    console.log("props.data:", props.data.length, "| ref:", defaultData.current.length);
    

    Rồi chuyển giữa các danh mục và quan sát 2 con số lệch nhau. Đây là bài tập giá trị nhất trong bài — hãy làm thật.

  6. Slider khoảng giá: thay 2 ô number bằng <input type="range"> đôi, hoặc dùng thư viện rc-slider.


⬅️ Bài trước | Mục lục | Bài tiếp theo: Trang giỏ hàng & đặt hàng ➡️


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí