Runnable lesson · press Try it on the examples to edit and run them here.
Product lists with search, filters and sorting appear in shops, catalogues, directories and admin panels. The skill here is combining several controls into one derived list.
Requirements
- Show products as cards with a name, category, price and stock status.
- Search by name (case-insensitive).
- Filter by category and by “in stock only”.
- Sort by name or price (low to high, high to low).
- Show how many products match.
- A friendly empty state with a button to clear all filters.
- Prices formatted as currency.
The solution
const PRODUCTS = [
{ id: 1, name: "Mechanical Keyboard", category: "Input", price: 89, inStock: true },
{ id: 2, name: "Wireless Mouse", category: "Input", price: 35, inStock: true },
{ id: 3, name: "27-inch Monitor", category: "Display", price: 249, inStock: false },
{ id: 4, name: "USB-C Hub", category: "Accessories", price: 45, inStock: true },
{ id: 5, name: "Studio Headphones", category: "Audio", price: 129, inStock: true },
{ id: 6, name: "Desk Speaker", category: "Audio", price: 59, inStock: false },
{ id: 7, name: "Webcam HD", category: "Accessories", price: 69, inStock: true },
{ id: 8, name: "Laptop Stand", category: "Accessories", price: 39, inStock: true },
];
const CATEGORIES = ["All", ...new Set(PRODUCTS.map((p) => p.category))];
const money = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 });
const SORTS = {
"name-asc": (a, b) => a.name.localeCompare(b.name),
"price-asc": (a, b) => a.price - b.price,
"price-desc": (a, b) => b.price - a.price,
};
function ProductCard({ product }) {
return (
<li style={{ border: "1px solid #d0d7de", borderRadius: 8, padding: 12, listStyle: "none", opacity: product.inStock ? 1 : 0.6 }}>
<strong>{product.name}</strong>
<div style={{ color: "#555" }}>{product.category}</div>
<div>{money.format(product.price)}</div>
<div style={{ color: product.inStock ? "#1e6b34" : "#b71c1c" }}>{product.inStock ? "In stock" : "Out of stock"}</div>
</li>
);
}
function App() {
const [query, setQuery] = React.useState("");
const [category, setCategory] = React.useState("All");
const [sort, setSort] = React.useState("name-asc");
const [inStockOnly, setInStockOnly] = React.useState(false);
// One chain of derived steps: filter, filter, filter, then sort a COPY
const q = query.trim().toLowerCase();
const results = PRODUCTS
.filter((p) => p.name.toLowerCase().includes(q))
.filter((p) => category === "All" || p.category === category)
.filter((p) => !inStockOnly || p.inStock)
.sort(SORTS[sort]); // filter() already made a new array, so sort() does not touch PRODUCTS
const filtersActive = q !== "" || category !== "All" || inStockOnly;
function clearFilters() {
setQuery("");
setCategory("All");
setInStockOnly(false);
}
return (
<main style={{ fontFamily: "system-ui, sans-serif" }}>
<h1>Products</h1>
<div style={{ display: "flex", gap: 12, flexWrap: "wrap", alignItems: "center", marginBottom: 12 }}>
<label>Search <input aria-label="Search" value={query} onChange={(e) => setQuery(e.target.value)} /></label>
<label>Category{" "}
<select aria-label="Category" value={category} onChange={(e) => setCategory(e.target.value)}>
{CATEGORIES.map((c) => <option key={c}>{c}</option>)}
</select>
</label>
<label>Sort by{" "}
<select aria-label="Sort by" value={sort} onChange={(e) => setSort(e.target.value)}>
<option value="name-asc">Name</option>
<option value="price-asc">Price: low to high</option>
<option value="price-desc">Price: high to low</option>
</select>
</label>
<label><input type="checkbox" checked={inStockOnly} onChange={(e) => setInStockOnly(e.target.checked)} /> In stock only</label>
</div>
<p role="status">Showing {results.length} product{results.length === 1 ? "" : "s"}</p>
{results.length === 0 ? (
<div>
<p>No products match your filters.</p>
<button onClick={clearFilters}>Clear filters</button>
</div>
) : (
<ul style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(180px, 1fr))", gap: 12, padding: 0 }}>
{results.map((p) => <ProductCard key={p.id} product={p} />)}
</ul>
)}
{filtersActive && results.length > 0 && <button onClick={clearFilters}>Clear filters</button>}
</main>
);
}
Walkthrough
- Four pieces of state, one derived list.
query,category,sortandinStockOnlyare the user’s choices.resultsis calculated from them and the originalPRODUCTSon every render (Filtering and Sorting Lists). The raw data is never changed. - A readable filter chain. Each
.filterhandles one control, so adding a control means adding one line.sortruns last. Becausefilterreturns a new array, callingsorton it does not mutatePRODUCTS. If you sort without filtering first, copy the array ([...PRODUCTS].sort(...)). - Lookup object for sorting.
SORTSmaps each option value to a compare function, which removes aswitchand lets the<select>and the logic share keys. - Categories come from the data.
new Set(PRODUCTS.map(...))removes duplicates, so a new category appears in the menu automatically. localeComparesorts names correctly for people.Intl.NumberFormatformats prices; the formatter is created once, outside the component, instead of on every render.- Small components.
ProductCardonly displays one product from props, which keepsAppfocused on state and layout (Component Composition). - Empty state and counts. The count is in a
role="status"region so screen reader users hear when filtering changes the result. The empty state explains what happened and offers Clear filters.
Decisions worth noticing
- Client-side filtering is right here because the list is small and already in the browser. For thousands of items, send the filters to a server and paginate (Pagination).
- Debouncing is unnecessary: filtering eight items is instant. With a server, debounce the search (Search and Filtering).
- Filters in the URL (
?q=mouse&category=Audio) let users share a filtered view; see Route Parameters. - Do not store
resultsin state. It would be a second copy that you must keep in sync.
Make it yours
- Add a price range slider (two number inputs) with validation.
- Highlight the matching text in names.
- Add pagination (6 per page) that resets to page 1 when filters change.
- Load the products from an API with loading and error states, keeping the same filter code.