Product Filters Sidebar
Machine-coding brief for product filters — URL-synced facets, multi-select, clear-all, and derived result counts.
- 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)
- Filter state object — serializable
- Pure
applyFilters(products, filters) - URL as source of truth vs state → URL write-through
- MVP — in-memory filters + list; then URL
- 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/legendper 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
- OR vs AND confusion within a group (usually OR within, AND across)
- Mutating filter arrays in place
- history.pushState every checkbox — use
replaceStateto avoid junk history - String vs number price from URL
- 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.