Back to Notes
UX2 phút đọcNovember 5, 2025

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.

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

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

tsx
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

tsx
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 &quot;{query}&quot;
          </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

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