ESC

Type to search the knowledge base.

Product Filters Sidebar

Machine-coding brief for product filters — URL-synced facets, multi-select, clear-all, and derived result counts.

intermediate4 min read
  • machine-coding
  • interview
  • react
  • state

Problem statement

Build a product filters sidebar: facets like category, price range, brand, rating; selecting filters narrows a product list. Interviewers score filter state shape, composable predicates, and often URL sync so refresh preserves filters.

Requirements

Must have

  • Multiple filter groups (checkbox multi-select + one range)
  • Product list updates from combined filters (AND across groups)
  • Clear group / clear all
  • Empty results state
  • Accessible fieldset/legend per group

Should have

  • Sync filters to query string (?brand=a,b&min=10)
  • Show result count
  • Disable options with zero matches (optional facet counts)

Nice to have

  • Debounced price inputs
  • Mobile filter drawer
  • Sort control

Planning (5 minutes out loud)

  1. Filter state object — serializable
  2. Pure applyFilters(products, filters)
  3. URL as source of truth vs state → URL write-through
  4. MVP — in-memory filters + list; then URL
  5. Don’t mutate products array

Architecture

ProductBrowse
├── FilterSidebar
│   ├── CheckboxGroup
│   └── PriceRange
├── ResultsHeader (count, clear)
└── ProductGrid

Types

type Product = {
  id: string;
  title: string;
  brand: string;
  category: string;
  price: number;
  rating: number;
};

type Filters = {
  brands: string[];
  categories: string[];
  minPrice: number | null;
  maxPrice: number | null;
  minRating: number | null;
};

const emptyFilters: Filters = {
  brands: [],
  categories: [],
  minPrice: null,
  maxPrice: null,
  minRating: null,
};

Implementation sketch

Apply filters

function applyFilters(products: Product[], f: Filters): Product[] {
  return products.filter((p) => {
    if (f.brands.length && !f.brands.includes(p.brand)) return false;
    if (f.categories.length && !f.categories.includes(p.category)) return false;
    if (f.minPrice != null && p.price < f.minPrice) return false;
    if (f.maxPrice != null && p.price > f.maxPrice) return false;
    if (f.minRating != null && p.rating < f.minRating) return false;
    return true;
  });
}

Checkbox group

function CheckboxGroup({
  legend,
  options,
  value,
  onChange,
}: {
  legend: string;
  options: string[];
  value: string[];
  onChange: (next: string[]) => void;
}) {
  function toggle(opt: string) {
    onChange(
      value.includes(opt) ? value.filter((v) => v !== opt) : [...value, opt]
    );
  }
  return (
    <fieldset>
      <legend>{legend}</legend>
      {options.map((opt) => (
        <label key={opt}>
          <input
            type="checkbox"
            checked={value.includes(opt)}
            onChange={() => toggle(opt)}
          />
          {opt}
        </label>
      ))}
    </fieldset>
  );
}

URL sync (should-have)

function filtersToParams(f: Filters): URLSearchParams {
  const p = new URLSearchParams();
  if (f.brands.length) p.set("brand", f.brands.join(","));
  if (f.categories.length) p.set("cat", f.categories.join(","));
  if (f.minPrice != null) p.set("min", String(f.minPrice));
  if (f.maxPrice != null) p.set("max", String(f.maxPrice));
  if (f.minRating != null) p.set("rating", String(f.minRating));
  return p;
}

function paramsToFilters(p: URLSearchParams): Filters {
  const num = (k: string) => {
    const v = p.get(k);
    if (v == null || v === "") return null;
    const n = Number(v);
    return Number.isFinite(n) ? n : null;
  };
  return {
    brands: p.get("brand")?.split(",").filter(Boolean) ?? [],
    categories: p.get("cat")?.split(",").filter(Boolean) ?? [],
    minPrice: num("min"),
    maxPrice: num("max"),
    minRating: num("rating"),
  };
}

// On filters change:
// const qs = filtersToParams(filters).toString();
// history.replaceState(null, "", qs ? `?${qs}` : window.location.pathname);

Initialize state from paramsToFilters(new URLSearchParams(location.search)).

Facet counts (optional)

function countByBrand(products: Product[], f: Filters, brand: string) {
  return applyFilters(products, { ...f, brands: [brand] }).length;
}

For true multi-select facet counts, compute with other groups applied but this brand forced — clarify with interviewer.

Accessibility essentials

  • fieldset / legend per group
  • Clear all as a real button
  • Results count announced politely when filters change
  • Price inputs labeled; type="number" + min/max

Performance notes

  • useMemo(() => applyFilters(products, filters), [products, filters])
  • Large catalogs: server-side filtering; client only for interview dataset
  • Debounce price range text fields (200–300ms)

Footguns

  1. OR vs AND confusion within a group (usually OR within, AND across)
  2. Mutating filter arrays in place
  3. history.pushState every checkbox — use replaceState to avoid junk history
  4. String vs number price from URL
  5. Empty list without clear CTA

Interview out-loud answer

Filters are a serializable object; products pass through a pure predicate combining groups with AND. Sidebar is controlled fieldsets; list derives via useMemo. I’d sync to the query string with replaceState so shares/refreshes work. Facet counts and server-side filtering are the scaling conversation.

Further reading