# 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
Productlistcủa source gốc - Viết lại
Productlistđúng chuẩn vớiuseMemo - Hiểu vì sao
useReflà 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 đề:
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ì.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.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
useMemonế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
- Đảm bảo nút "Mặc Định" đang được chọn (nền đen)
- Nhập khoảng giá: từ
3000000đến4000000 - ✅ 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
- Bấm "Giá tăng"
- Bấm danh mục "Gucci" ở sidebar
- Bấm "Mặc Định"
- ✅ 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
- Bấm "Giá giảm" → sản phẩm đắt nhất lên đầu
- Bấm "Giá tăng" → sản phẩm rẻ nhất lên đầu
- Bấm "Mặc Định" → trở về thứ tự ban đầu từ API
- ✅ Bấm qua lại nhiều lần, kết quả luôn nhất quán
Test tổng hợp
- Danh mục "Gucci" + khoảng giá 4tr–6tr + Giá tăng
- Cả 3 điều kiện phải cùng có hiệu lực
Test trường hợp rỗng
- Nhập khoảng giá
1đến2 - ✅ 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
-
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). -
Hiện số lượng kết quả:
Tìm thấy 5 sản phẩmphía trên lưới. -
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)
- Theo tên A→Z:
-
Đồng bộ bộ lọc với URL: dùng
useSearchParamsvà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. -
Tái hiện bug 1 để hiểu sâu: tạm thời khôi phục
useReftrongProductlist, 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.
-
Slider khoảng giá: thay 2 ô number bằng
<input type="range">đôi, hoặc dùng thư việnrc-slider.
⬅️ Bài trước | Mục lục | Bài tiếp theo: Trang giỏ hàng & đặt hàng ➡️
All rights reserved