Skip to content

React Tutorial Practical Projects

React Searchable Product List Project

Lesson 143 of 156 2 min read By Deepak Paswan Updated Oct 10, 2026

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

App.jsx
JSX
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

  1. Four pieces of state, one derived list. query, category, sort and inStockOnly are the user’s choices. results is calculated from them and the original PRODUCTS on every render (Filtering and Sorting Lists). The raw data is never changed.
  2. A readable filter chain. Each .filter handles one control, so adding a control means adding one line. sort runs last. Because filter returns a new array, calling sort on it does not mutate PRODUCTS. If you sort without filtering first, copy the array ([...PRODUCTS].sort(...)).
  3. Lookup object for sorting. SORTS maps each option value to a compare function, which removes a switch and lets the <select> and the logic share keys.
  4. Categories come from the data. new Set(PRODUCTS.map(...)) removes duplicates, so a new category appears in the menu automatically.
  5. localeCompare sorts names correctly for people.
  6. Intl.NumberFormat formats prices; the formatter is created once, outside the component, instead of on every render.
  7. Small components. ProductCard only displays one product from props, which keeps App focused on state and layout (Component Composition).
  8. 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 results in state. It would be a second copy that you must keep in sync.

Make it yours

  1. Add a price range slider (two number inputs) with validation.
  2. Highlight the matching text in names.
  3. Add pagination (6 per page) that resets to page 1 when filters change.
  4. Load the products from an API with loading and error states, keeping the same filter code.