Search & Filter UX — Tại sao search ecommerce khó hơn bạn nghĩ
Search là feature được dùng nhiều nhất trên ecommerce nhưng cũng dễ bị làm sai nhất. Debounce, URL state, faceted filter, empty state và typo tolerance — đây là những gì một search UX tốt cần có.
Search & Filter UX — Tại sao search ecommerce khó hơn bạn nghĩ
User gõ "giày nike" vào search bar và mong thấy kết quả ngay lập tức. Nhưng phía sau đó là: debounce logic, URL sync, filter state, loading state, empty state, và typo tolerance. Mỗi phần đều có edge case riêng.
Vấn đề 1: Debounce vs Instant Search
// hooks/useSearch.ts
export function useSearch() {
const router = useRouter();
const searchParams = useSearchParams();
const [inputValue, setInputValue] = useState(searchParams.get("q") ?? "");
// Debounce chỉ việc cập nhật URL — không debounce UI
const debouncedPushUrl = useMemo(
() =>
debounce((value: string) => {
const params = new URLSearchParams(searchParams.toString());
if (value) {
params.set("q", value);
} else {
params.delete("q");
}
params.set("page", "1");
router.push(`?${params.toString()}`, { scroll: false });
}, 300),
[router, searchParams]
);
const handleChange = (value: string) => {
setInputValue(value); // Update UI ngay (không debounce)
debouncedPushUrl(value); // Debounce việc fetch/URL update
};
return { inputValue, handleChange };
}Rule: Debounce 300ms là sweet spot — đủ nhanh để user cảm thấy responsive, đủ chậm để không spam API. Với Algolia/Typesense, có thể giảm xuống 150ms vì latency thấp.
Vấn đề 2: URL-driven Filter State
// Tất cả filter state sống trong URL
// ?q=giày+nike&brand=Nike,Adidas&price=500000-2000000&sort=popular&page=2
function FilterPanel() {
const router = useRouter();
const searchParams = useSearchParams();
const selectedBrands = searchParams.get("brand")?.split(",") ?? [];
const toggleBrand = (brand: string) => {
const params = new URLSearchParams(searchParams.toString());
const current = params.get("brand")?.split(",").filter(Boolean) ?? [];
const updated = current.includes(brand)
? current.filter((b) => b !== brand)
: [...current, brand];
if (updated.length > 0) {
params.set("brand", updated.join(","));
} else {
params.delete("brand");
}
params.set("page", "1");
router.push(`?${params.toString()}`, { scroll: false });
};
return (
<div>
{availableBrands.map((brand) => (
<label key={brand} className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={selectedBrands.includes(brand)}
onChange={() => toggleBrand(brand)}
/>
{brand}
</label>
))}
</div>
);
}Vấn đề 3: Faceted Search — Loading State per Facet
Khi user chọn filter "Nike", count của các filter khác (price range, category) cần update. Đừng block toàn bộ UI khi đang fetch.
function PriceRangeFilter({ isUpdating }: { isUpdating: boolean }) {
return (
<div className={isUpdating ? "opacity-50 pointer-events-none" : ""}>
<h3 className="font-medium">
Khoảng giá
{isUpdating && <Spinner className="ml-2 inline w-4 h-4" />}
</h3>
{priceRanges.map((range) => (
<label key={range.id} className="flex items-center justify-between">
<span>{range.label}</span>
<span className="text-gray-400 text-sm">
{/* Hiển thị count cũ với opacity thấp trong khi update */}
<span className={isUpdating ? "opacity-40" : ""}>{range.count}</span>
</span>
</label>
))}
</div>
);
}Vấn đề 4: Empty State Design
function SearchEmptyState({
query,
activeFilters,
}: {
query: string;
activeFilters: Filter[];
}) {
const hasFilters = activeFilters.length > 0;
return (
<div className="text-center py-16">
<SearchXIcon className="w-16 h-16 mx-auto text-gray-300 mb-4" />
{hasFilters ? (
<>
<h3 className="text-lg font-medium">Không tìm thấy kết quả phù hợp</h3>
<p className="text-gray-500 mt-2">
Thử bỏ bớt bộ lọc để xem thêm sản phẩm
</p>
<button onClick={clearAllFilters} className="mt-4 underline text-blue-600">
Xóa tất cả bộ lọc
</button>
</>
) : (
<>
<h3 className="text-lg font-medium">
Không tìm thấy "{query}"
</h3>
<p className="text-gray-500 mt-2">Kiểm tra lại chính tả hoặc thử từ khóa khác</p>
<div className="mt-6">
<p className="text-sm text-gray-400 mb-2">Bạn có thể thích:</p>
<SuggestedCategories />
</div>
</>
)}
</div>
);
}Vấn đề 5: Typo Tolerance
// Với Algolia — typo tolerance có sẵn, chỉ cần config
const index = algoliasearch(APP_ID, SEARCH_KEY).initIndex("products");
const { hits } = await index.search(query, {
typoTolerance: true, // Default: true
minWordSizefor1Typo: 4, // Từ >= 4 ký tự mới bật typo tolerance
minWordSizefor2Typos: 8,
});
// Tự build fuzzy search đơn giản với Fuse.js (client-side, danh sách nhỏ)
import Fuse from "fuse.js";
const fuse = new Fuse(products, {
keys: ["name", "brand", "category"],
threshold: 0.3, // 0 = exact match, 1 = match anything
distance: 100,
includeScore: true,
});
const results = fuse.search(query).map((r) => r.item);Key Takeaways
- Debounce: debounce việc gọi API/update URL, không debounce UI input state
- URL state: toàn bộ filter/sort/page trong URL — shareable, SEO-friendly, back/forward work
- Faceted loading: disable filter panel khi đang fetch, giữ count cũ với opacity thấp
- Empty state: phân biệt "không có kết quả do filter" vs "không có kết quả do query"
- Typo tolerance: Algolia/Typesense cho production; Fuse.js cho client-side nhỏ
If you found this helpful, leave a like!
Related Posts
Comments available after DB is connected.