Build a Product Catalog Dashboard

Hand-write React hooks, state management, and components from scratch.

Introduction

30 Second Summary

AI tools can write React code for you, but they can't sit in your interview chair. When a hiring manager asks you to build a filtered list from scratch, you need the muscle memory to do it yourself.

In this project, you will build a Product Catalog Dashboard using only hand-written React code. You will create custom hooks, wire up search and filtering, and manage state without any AI assistance.

What You'll Build

A single-page Product Catalog Dashboard that displays a grid of product cards with debounced search, category filtering, sort controls, and persistent filter preferences stored in localStorage.

By the end of this project, you'll have:

  • A working Product Catalog Dashboard that filters, searches, and sorts a product list using only hand-written React code.
  • Two custom hooks (useDebounce and useLocalStorage) built from scratch that you can explain in an interview.
  • Hands-on understanding of useState, useEffect, and useMemo with the vocabulary to describe what each does and why.
  • Secret Mission: Add pagination with "Previous" and "Next" buttons so users can browse products page by page.

Are there any prerequisites?

Basic familiarity with HTML, CSS, and JavaScript is all you need. Some exposure to React (even AI-assisted) is helpful but not required.

Scaffold the Project with Vite

Every React project starts with a build tool that handles bundling, hot reloading, and a development server. For this project, that tool is Vite.

In this step, you'll scaffold a fresh React project, strip out the demo files you don't need, and start the development server. By the end, you'll have a blank canvas ready for your Product Catalog Dashboard.

In this step, get ready to:
  • Scaffold a new React project using Vite.
  • Clean out the default scaffold files.
  • Start the dev server and verify it runs in the browser.
Scaffold a new React project with Vite

Vite creates a ready-to-run React project with a single command. It handles bundling, hot module reloading, and a local development server out of the box.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Scaffold your React project by running this command:
npm create vite@latest product-catalog -- --template react

What does this command do?

  • npm create vite@latest runs Vite's project scaffolding tool.
  • product-catalog names the folder that gets created for your project.
  • --template react tells Vite to set up a React project with JavaScript.

If npm asks to install the create-vite package, type y and press Enter.

  • Move into your new project folder and install its dependencies by running:
cd product-catalog
npm install

What does npm install do?

npm install reads the package.json file that Vite generated and downloads every library your project needs (React, Vite itself, etc.) into a node_modules folder.

  • Confirm the installation worked by running:
ls

You should see node_modules, public, src, and other project files in the output.

Seeing 'command not found' or install errors?

  • Make sure Node.js is installed by running node --version. You need Node.js 20.19 or higher.
  • If you see a permissions error, try closing your terminal and reopening it.
  • Check that you ran cd ~/Desktop first. The scaffold command creates a folder in whatever directory your terminal is currently in.

Still stuck? Help me troubleshoot my Vite scaffold command.

Clean out the default scaffold files

Vite's scaffold comes with demo content like logos, animations, and placeholder text. You'll clear all of that out so you're starting with a blank canvas.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Visual Studio Code and press Enter to open it.
  • Click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop, select the product-catalog folder, then click Open.

You should see the project files in the Explorer sidebar on the left.

  • In the Explorer sidebar, expand the src folder.
  • Right-click the assets folder inside src.
  • Click Delete to remove it.
  • Click the App.css file inside src to open it.
  • Select all the content with Cmd+A (macOS) or Ctrl+A (Windows), then press Delete.
  • Save the now-empty file with Cmd+S (macOS) or Ctrl+S (Windows).
  • Click App.jsx inside src to open it.
  • Select all the content with Cmd+A (macOS) or Ctrl+A (Windows), then delete it.
  • Replace the contents with this code:
function App() {
  return <h1>Product Catalog</h1>;
}

export default App;

What does this code do?

This is the simplest possible React component. The App function returns a single <h1> heading, which is all your page will show for now.

export default App makes this component available to main.jsx, which is the file that loads it into the page.

  • Save App.jsx with Cmd+S (macOS) or Ctrl+S (Windows).

You won't see a change in the browser yet. The next substep starts the dev server so you can see your clean project.

✔️ Awesome, I've got everything!

Great. Double check that you've saved both App.jsx and App.css, and that the assets folder is gone from your src directory.

ⓧ I'd like to double check the full code

Here's what your src/App.jsx should look like:

function App() {
  return <h1>Product Catalog</h1>;
}

export default App;

Your src/App.css should be completely empty. No CSS rules, no comments, just a blank file.

Start the dev server

Time to see your clean project running in the browser. Vite includes a development server that automatically refreshes the page whenever you save a file.

  • Open the integrated terminal in VS Code by pressing Ctrl+` (backtick key, next to the 1 key on your keyboard).
  • Start the development server by running:
npm run dev

What does npm run dev do?

This starts Vite's local development server. The server watches your files and automatically reloads the browser whenever you save a change. Your terminal will stay busy running the server, so leave it running.

  • Open your browser and navigate to http://localhost:5173.

You should see a page with a "Product Catalog" heading and nothing else. That blank canvas is exactly what you want.

Don't see the page?

  • Check that your terminal shows a URL like http://localhost:5173/. If it shows an error instead, read the error message for clues.
  • Make sure you saved both App.jsx and App.css. Unsaved changes can cause import errors.
  • If you see a blank white page, open your browser's DevTools console (F12) and look for red error messages.

Still stuck? Help me troubleshoot my Vite dev server.

Your project is scaffolded and running. Next up, you'll create the product data and build your first component.

Create the Product Data and ProductCard Component

Your scaffold is live and showing a heading in the browser. Now it's time to give the app something to display.

In this step, you'll create a dataset of 16 products and build a ProductCard component to render each one. By the end, your browser will show a full grid of product cards with images, names, categories, and prices.

In this step, get ready to:
  • Create a product data module with 16 items across 4 categories.
  • Build a reusable ProductCard component with styling.
  • Render all products in a grid inside App.jsx.
Create the product data module

Every product catalog needs a data source. You'll create a JavaScript module that exports an array of product objects, each with an ID, name, price, category, and placeholder image URL.

  • In VS Code, find the Explorer sidebar on the left.
  • Right-click on the src folder and click New Folder.
  • Name the folder data.
  • Right-click on the new data folder and click New File.
  • Name the file products.js.
  • Paste the following product data into src/data/products.js:
// Product catalog data - 16 products across 4 categories
const products = [
  { id: 1, name: "Wireless Headphones", price: 79.99, category: "Electronics", image: "https://picsum.photos/seed/headphones/300/200" },
  { id: 2, name: "Running Shoes", price: 129.99, category: "Clothing", image: "https://picsum.photos/seed/shoes/300/200" },
  { id: 3, name: "JavaScript: The Good Parts", price: 29.99, category: "Books", image: "https://picsum.photos/seed/jsbook/300/200" },
  { id: 4, name: "Ceramic Plant Pot", price: 24.99, category: "Home", image: "https://picsum.photos/seed/plantpot/300/200" },
  { id: 5, name: "Bluetooth Speaker", price: 49.99, category: "Electronics", image: "https://picsum.photos/seed/speaker/300/200" },
  { id: 6, name: "Denim Jacket", price: 89.99, category: "Clothing", image: "https://picsum.photos/seed/jacket/300/200" },
  { id: 7, name: "Learning React", price: 39.99, category: "Books", image: "https://picsum.photos/seed/reactbook/300/200" },
  { id: 8, name: "Scented Candle Set", price: 19.99, category: "Home", image: "https://picsum.photos/seed/candle/300/200" },
  { id: 9, name: "USB-C Hub", price: 34.99, category: "Electronics", image: "https://picsum.photos/seed/usbhub/300/200" },
  { id: 10, name: "Cotton T-Shirt", price: 19.99, category: "Clothing", image: "https://picsum.photos/seed/tshirt/300/200" },
  { id: 11, name: "Eloquent JavaScript", price: 34.99, category: "Books", image: "https://picsum.photos/seed/eloqjs/300/200" },
  { id: 12, name: "Throw Blanket", price: 44.99, category: "Home", image: "https://picsum.photos/seed/blanket/300/200" },
  { id: 13, name: "Mechanical Keyboard", price: 149.99, category: "Electronics", image: "https://picsum.photos/seed/keyboard/300/200" },
  { id: 14, name: "Wool Sweater", price: 69.99, category: "Clothing", image: "https://picsum.photos/seed/sweater/300/200" },
  { id: 15, name: "CSS in Depth", price: 44.99, category: "Books", image: "https://picsum.photos/seed/cssbook/300/200" },
  { id: 16, name: "Wall Clock", price: 29.99, category: "Home", image: "https://picsum.photos/seed/clock/300/200" },
];

export default products;

What does this code do?

This file defines an array of 16 product objects and exports it as the default export. Each object has five fields:

  • id is a unique number so React can track each product when rendering lists.
  • name, price, and category are the display fields you'll filter and sort later.
  • image uses picsum.photos placeholder URLs. Each seed keyword produces a consistent image without needing to host anything.
  • Save products.js.

You won't see a change in the browser yet. The next substep builds the component that displays this data.

Seeing a red squiggly line in VS Code?

  • Check that every product object has matching curly braces { } and every string has matching double quotes.
  • Make sure there's a comma after each object in the array, including the last one.
  • Confirm the file ends with export default products; outside the array brackets.

Still stuck? Ask for help here.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

const products = [
  { id: 1, name: "Wireless Headphones", price: 79.99, category: "Electronics", image: "https://picsum.photos/seed/headphones/300/200" },
  { id: 2, name: "Running Shoes", price: 129.99, category: "Clothing", image: "https://picsum.photos/seed/shoes/300/200" },
  { id: 3, name: "JavaScript: The Good Parts", price: 29.99, category: "Books", image: "https://picsum.photos/seed/jsbook/300/200" },
  { id: 4, name: "Ceramic Plant Pot", price: 24.99, category: "Home", image: "https://picsum.photos/seed/plantpot/300/200" },
  { id: 5, name: "Bluetooth Speaker", price: 49.99, category: "Electronics", image: "https://picsum.photos/seed/speaker/300/200" },
  { id: 6, name: "Denim Jacket", price: 89.99, category: "Clothing", image: "https://picsum.photos/seed/jacket/300/200" },
  { id: 7, name: "Learning React", price: 39.99, category: "Books", image: "https://picsum.photos/seed/reactbook/300/200" },
  { id: 8, name: "Scented Candle Set", price: 19.99, category: "Home", image: "https://picsum.photos/seed/candle/300/200" },
  { id: 9, name: "USB-C Hub", price: 34.99, category: "Electronics", image: "https://picsum.photos/seed/usbhub/300/200" },
  { id: 10, name: "Cotton T-Shirt", price: 19.99, category: "Clothing", image: "https://picsum.photos/seed/tshirt/300/200" },
  { id: 11, name: "Eloquent JavaScript", price: 34.99, category: "Books", image: "https://picsum.photos/seed/eloqjs/300/200" },
  { id: 12, name: "Throw Blanket", price: 44.99, category: "Home", image: "https://picsum.photos/seed/blanket/300/200" },
  { id: 13, name: "Mechanical Keyboard", price: 149.99, category: "Electronics", image: "https://picsum.photos/seed/keyboard/300/200" },
  { id: 14, name: "Wool Sweater", price: 69.99, category: "Clothing", image: "https://picsum.photos/seed/sweater/300/200" },
  { id: 15, name: "CSS in Depth", price: 44.99, category: "Books", image: "https://picsum.photos/seed/cssbook/300/200" },
  { id: 16, name: "Wall Clock", price: 29.99, category: "Home", image: "https://picsum.photos/seed/clock/300/200" },
];

export default products;
Build the ProductCard component

A presentational component has one job: receive data through props and render it. ProductCard won't manage any state of its own.

  • Right-click on the src folder in the Explorer sidebar and click New Folder.
  • Name the folder components.
  • Right-click on the new components folder and click New File.
  • Name the file ProductCard.jsx.
  • Paste the following code into src/components/ProductCard.jsx:
import "./ProductCard.css";

// Each ProductCard receives a single product object as a prop
function ProductCard({ product }) {
  return (
    <div className="product-card">
      {/* Product image */}
      <img
        className="product-card__image"
        src={product.image}
        alt={product.name}
      />
      {/* Product details */}
      <div className="product-card__info">
        <span className="product-card__category">{product.category}</span>
        <h3 className="product-card__name">{product.name}</h3>
        {/* toFixed(2) ensures prices always show two decimal places */}
        <p className="product-card__price">${product.price.toFixed(2)}</p>
      </div>
    </div>
  );
}

export default ProductCard;

What does this code do?

  • { product } in the function signature is called destructuring. It pulls the product property directly out of the props object.
  • The component returns JSX that renders the product's image, category badge, name, and price.
  • .toFixed(2) formats the price to always show two decimal places. Without it, a price of 20 would render as $20 instead of $20.00.
  • The import at the top pulls in a CSS file that doesn't exist yet. You'll create it next.
  • Save ProductCard.jsx.

You won't see a change in the browser yet. The component needs its CSS file and a parent to render it.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

import "./ProductCard.css";

function ProductCard({ product }) {
  return (
    <div className="product-card">
      <img
        className="product-card__image"
        src={product.image}
        alt={product.name}
      />
      <div className="product-card__info">
        <span className="product-card__category">{product.category}</span>
        <h3 className="product-card__name">{product.name}</h3>
        <p className="product-card__price">${product.price.toFixed(2)}</p>
      </div>
    </div>
  );
}

export default ProductCard;

Now create the CSS file that styles the card.

  • Right-click on the components folder and click New File.
  • Name the file ProductCard.css.
  • Paste the following styles into src/components/ProductCard.css:
/* Card container with border and hover effect */
.product-card {
  border: 1px solid #e0e0e0;
  border-radius: 8px;
  overflow: hidden;
  background: #fff;
  transition: box-shadow 0.2s ease;
}

.product-card:hover {
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}

/* Product image fills the card width */
.product-card__image {
  width: 100%;
  height: 160px;
  object-fit: cover;
}

/* Info section with padding below the image */
.product-card__info {
  padding: 12px;
}

/* Small category badge */
.product-card__category {
  display: inline-block;
  background: #f0f0f0;
  color: #555;
  font-size: 0.75rem;
  padding: 2px 8px;
  border-radius: 4px;
  margin-bottom: 8px;
}

.product-card__name {
  margin: 4px 0;
  font-size: 1rem;
  color: #222;
}

/* Price in blue with heavier weight */
.product-card__price {
  font-size: 1.1rem;
  font-weight: 600;
  color: #1a73e8;
  margin: 4px 0 0;
}

What do these styles do?

  • .product-card gives each card a border, rounded corners, and a subtle shadow on hover.
  • object-fit: cover on the image makes it fill the container without stretching or distorting.
  • .product-card__category styles the category as a small rounded badge with a gray background.
  • The price gets a blue color (#1a73e8) and heavier font weight so it stands out at a glance.
  • Save ProductCard.css.

The component and its styles are ready. The next substep wires them into App.jsx so you can see the cards in the browser.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

.product-card {
  border: 1px solid #e0e0e0;
  border-radius: 8px;
  overflow: hidden;
  background: #fff;
  transition: box-shadow 0.2s ease;
}

.product-card:hover {
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}

.product-card__image {
  width: 100%;
  height: 160px;
  object-fit: cover;
}

.product-card__info {
  padding: 12px;
}

.product-card__category {
  display: inline-block;
  background: #f0f0f0;
  color: #555;
  font-size: 0.75rem;
  padding: 2px 8px;
  border-radius: 4px;
  margin-bottom: 8px;
}

.product-card__name {
  margin: 4px 0;
  font-size: 1rem;
  color: #222;
}

.product-card__price {
  font-size: 1.1rem;
  font-weight: 600;
  color: #1a73e8;
  margin: 4px 0 0;
}
Render the product grid in App.jsx

You have the data and the component. Now you need to connect them inside App.jsx so every product renders as a card.

  • Open src/App.jsx in VS Code.
  • Replace all the code in the file with the following:
// Import the product data and the card component
import products from "./data/products";
import ProductCard from "./components/ProductCard";
import "./App.css";

function App() {
  return (
    <div className="app">
      <header className="app__header">
        <h1>Product Catalog</h1>
      </header>
      {/* Map over the products array to render one card per product */}
      <div className="app__grid">
        {products.map((product) => (
          <ProductCard key={product.id} product={product} />
        ))}
      </div>
    </div>
  );
}

export default App;

What does this code do?

  • products.map() loops through every product in the array and returns a <ProductCard /> for each one. This is how React renders lists.
  • key={product.id} gives each card a stable identifier so React can efficiently update the list when items change.
  • product={product} passes the entire product object as a prop to ProductCard. This is the data flow: parent passes props down, child renders them.
  • Save App.jsx.
  • Switch to your browser at http://localhost:5173. You should see 16 product cards displayed in a grid layout, each with a placeholder image, a gray category badge, a product name, and a blue price.

Don't see any products?

  • Check the browser console (Cmd+Option+J on macOS, Ctrl+Shift+J on Windows) for error messages.
  • Verify the import path matches exactly: ./data/products and ./components/ProductCard. These are case-sensitive.
  • Make sure products.js has export default products; at the bottom. Without the export, the import in App.jsx can't find the array.
  • Confirm your dev server is still running. If it stopped, run npm run dev again in your terminal.

Help me debug why my product cards aren't showing up.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

import products from "./data/products";
import ProductCard from "./components/ProductCard";
import "./App.css";

function App() {
  return (
    <div className="app">
      <header className="app__header">
        <h1>Product Catalog</h1>
      </header>
      <div className="app__grid">
        {products.map((product) => (
          <ProductCard key={product.id} product={product} />
        ))}
      </div>
    </div>
  );
}

export default App;

Why isn't the grid layout styled yet?

Your App.css file is still empty from the cleanup in Step 1. The cards display in a vertical stack because there are no grid styles yet. You'll add layout styles to App.css in a later step.

You now have real product data and a reusable component to display it. Next up, you'll build a search bar with a custom hook that makes typing feel smooth by debouncing the input.

Add Search with a Custom useDebounce Hook

Your product grid is up and running with all 16 products on screen. Now it is time to let users find what they are looking for.

You will build a search bar that filters products by name as the user types. To keep things performant, you will also write your first custom hook to debounce the search input.

In this step, get ready to:
  • Build a custom useDebounce hook that delays value updates.
  • Create a SearchBar component with debounced input.
  • Wire the search bar into App.jsx and filter products by name.
Build the useDebounce custom hook

When a user types into a search box, every keystroke could trigger a filter operation across the entire product list. Debouncing solves this by waiting until the user pauses typing before running the expensive work.

What is debouncing?

Debouncing delays a function call until a set amount of time has passed since the last trigger. If the user types "head" in the search box, the filter only runs once after the last keystroke, not four separate times.

You will package this logic into a custom React hook so any component in the project can reuse it.

  • Create a new folder called hooks inside your src folder by running this command from your product-catalog directory:
mkdir src/hooks
  • Create a new file called useDebounce.js inside src/hooks/ by running:
touch src/hooks/useDebounce.js
  • Confirm the file exists by running:
ls src/hooks

You should see useDebounce.js in the output.

  • Open src/hooks/useDebounce.js in VS Code and add the following code:
import { useState, useEffect } from "react";

// Custom hook that delays updating a value until after a pause
function useDebounce(value, delay) {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    // Set a timer to update the debounced value after the delay
    const timer = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    // Cleanup: clear the timer if value changes before delay expires
    return () => {
      clearTimeout(timer);
    };
  }, [value, delay]);

  return debouncedValue;
}

export default useDebounce;

What does this code do?

This hook takes a value and a delay in milliseconds. It uses useState to store a copy of the value, and useEffect to start a timer every time the value changes.

The cleanup function (the return () => clearTimeout(timer) line) is the key piece. Every time the value changes, React runs the cleanup from the previous render before setting up the new timer. This cancels the old timeout and starts a fresh one, so the debounced value only updates after the user stops changing the input.

  • Save useDebounce.js.

You will not see a visible change yet. The next component, SearchBar, will put this hook to work.

✔️ Awesome, I've got everything!

Double check you have saved src/hooks/useDebounce.js before moving on.

ⓧ I'd like to double check the full code

import { useState, useEffect } from "react";

function useDebounce(value, delay) {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const timer = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    return () => {
      clearTimeout(timer);
    };
  }, [value, delay]);

  return debouncedValue;
}

export default useDebounce;
Create the SearchBar component

The SearchBar needs to handle two different speeds. The text input must update instantly so the user sees what they are typing. The actual search filter should only run after the user pauses.

What is a controlled component?

A controlled component is an input whose value is driven by React state. Instead of letting the browser manage what is inside the text field, you store the value in a useState variable and update it on every keystroke through onChange.

This gives you full control over the input value at all times.

  • Create a new file called SearchBar.jsx inside src/components/ by running:
touch src/components/SearchBar.jsx
  • Open src/components/SearchBar.jsx and add the following code:
import { useState, useEffect } from "react";
import useDebounce from "../hooks/useDebounce";

function SearchBar({ onSearch }) {
  // Raw input value updates instantly as the user types
  const [query, setQuery] = useState("");
  // Debounced value only updates after 300ms of no typing
  const debouncedQuery = useDebounce(query, 300);

  // Notify the parent component when the debounced value changes
  useEffect(() => {
    onSearch(debouncedQuery);
  }, [debouncedQuery, onSearch]);

  return (
    <div className="search-bar">
      <input
        type="text"
        placeholder="Search products..."
        value={query}
        onChange={(e) => setQuery(e.target.value)}
      />
    </div>
  );
}

export default SearchBar;

What does this code do?

  • query stores the raw text from the input field. It updates on every keystroke so the user sees instant feedback.
  • debouncedQuery is the output of your useDebounce hook. It only updates after 300ms of silence.
  • The useEffect calls onSearch(debouncedQuery) to pass the debounced value up to the parent. The parent decides what to do with it (filtering).

This pattern separates the fast UI updates (typing) from the expensive work (filtering the product list).

  • Save SearchBar.jsx.

No visible change yet. The next substep wires SearchBar into your App so you can see it in action.

✔️ Awesome, I've got everything!

Double check you have saved src/components/SearchBar.jsx before moving on.

ⓧ I'd like to double check the full code

import { useState, useEffect } from "react";
import useDebounce from "../hooks/useDebounce";

function SearchBar({ onSearch }) {
  const [query, setQuery] = useState("");
  const debouncedQuery = useDebounce(query, 300);

  useEffect(() => {
    onSearch(debouncedQuery);
  }, [debouncedQuery, onSearch]);

  return (
    <div className="search-bar">
      <input
        type="text"
        placeholder="Search products..."
        value={query}
        onChange={(e) => setQuery(e.target.value)}
      />
    </div>
  );
}

export default SearchBar;
Wire SearchBar into App.jsx

You have the hook and the component. Now you need to connect them to your App so the search bar actually filters the product grid.

This means adding state in App to hold the search term, passing a callback to SearchBar, and filtering the products array before rendering.

  • Open src/App.jsx and replace its entire contents with the following code:
import { useState, useCallback } from "react";
import products from "./data/products";
import SearchBar from "./components/SearchBar";
import ProductCard from "./components/ProductCard";
import "./App.css";

function App() {
  // Track the current search term from the SearchBar
  const [searchTerm, setSearchTerm] = useState("");

  // Stable callback reference so SearchBar's useEffect doesn't re-fire every render
  const handleSearch = useCallback((term) => {
    setSearchTerm(term);
  }, []);

  // Filter products whose name matches the search term
  const filteredProducts = products.filter((product) =>
    product.name.toLowerCase().includes(searchTerm.toLowerCase())
  );

  return (
    <div className="app">
      <header className="app__header">
        <h1>Product Catalog</h1>
        <p>{filteredProducts.length} products found</p>
      </header>
      <div className="app__controls">
        <SearchBar onSearch={handleSearch} />
      </div>
      <div className="app__grid">
        {filteredProducts.map((product) => (
          <ProductCard key={product.id} product={product} />
        ))}
      </div>
    </div>
  );
}

export default App;

What does this code do?

  • searchTerm holds the debounced search value that SearchBar sends up via the onSearch callback.
  • handleSearch is wrapped in useCallback to keep a stable function reference across renders. Without this, React would create a brand new function on every render, causing SearchBar's useEffect to fire every single time.
  • filteredProducts uses .filter() with .toLowerCase() on both the product name and search term so the search is case-insensitive.
  • The JSX now maps over filteredProducts instead of the full products array, and the header shows a live count of how many products matched.

Why useCallback here?

SearchBar lists onSearch in its useEffect dependency array. React compares dependencies using Object.is, which checks referential equality.

Without useCallback, every App render creates a new function object for handleSearch. React sees a "new" reference and re-runs SearchBar's effect on every render. useCallback caches the function so the reference stays the same.

  • Save App.jsx.
  • Go to your browser at http://localhost:5173. You should see a search input at the top of the page and "16 products found" in the header.
  • Type keyboard into the search box. After a brief delay, the grid should filter down to one product: Mechanical Keyboard.
  • Clear the search box. All 16 products should reappear.

Search not filtering products?

  • Check the browser console for errors. A common one is onSearch is not a function. This means the onSearch prop is not being passed to SearchBar in App.jsx.
  • Make sure App.jsx maps over filteredProducts in the JSX, not the original products array.
  • Verify that the import path in SearchBar.jsx is ../hooks/useDebounce (not ./hooks/useDebounce).

Still stuck? Help me debug why my SearchBar component is not filtering products.

✔️ Awesome, I've got everything!

Double check you have saved App.jsx and that your search is filtering correctly.

ⓧ I'd like to double check the full code

import { useState, useCallback } from "react";
import products from "./data/products";
import SearchBar from "./components/SearchBar";
import ProductCard from "./components/ProductCard";
import "./App.css";

function App() {
  const [searchTerm, setSearchTerm] = useState("");

  const handleSearch = useCallback((term) => {
    setSearchTerm(term);
  }, []);

  const filteredProducts = products.filter((product) =>
    product.name.toLowerCase().includes(searchTerm.toLowerCase())
  );

  return (
    <div className="app">
      <header className="app__header">
        <h1>Product Catalog</h1>
        <p>{filteredProducts.length} products found</p>
      </header>
      <div className="app__controls">
        <SearchBar onSearch={handleSearch} />
      </div>
      <div className="app__grid">
        {filteredProducts.map((product) => (
          <ProductCard key={product.id} product={product} />
        ))}
      </div>
    </div>
  );
}

export default App;

Your search bar is working and your first custom hook is in the bag. Next up, you will add category filtering and sort controls to give users even more ways to explore the catalog.

Add Category Filtering and Sort Controls

Your search bar filters products by name with debounced input. Users can type and see results update smoothly after a short pause.

A real product catalog needs more than search. In this step, you'll build category filter buttons and a sort dropdown, then chain all three controls into a single filtering pipeline inside App.jsx.

In this step, get ready to:
  • Create a CategoryFilter component with toggle buttons for each product category.
  • Create a SortControls component with a dropdown for price and name sorting.
  • Wire both components into App.jsx so search, category, and sort all work together.
Create the CategoryFilter component

The CategoryFilter won't own any state. It receives the list of categories, the currently selected category, and a callback function as props. The parent component (App.jsx) decides which category is active. This pattern is called lifting state up.

  • Create a new file at src/components/CategoryFilter.jsx and paste the following code:
function CategoryFilter({ categories, selected, onSelect }) {
  return (
    <div className="category-filter">
      {/* "All" button always appears first */}
      <button
        className={selected === "All" ? "active" : ""}
        onClick={() => onSelect("All")}
      >
        All
      </button>
      {/* One button per category, highlighted when active */}
      {categories.map((category) => (
        <button
          key={category}
          className={selected === category ? "active" : ""}
          onClick={() => onSelect(category)}
        >
          {category}
        </button>
      ))}
    </div>
  );
}

export default CategoryFilter;

What does this code do?

  • The component destructures three props: categories (an array of strings), selected (the currently active category), and onSelect (a callback to tell the parent which button was clicked).
  • Each button checks if it's the active one using a ternary expression. If selected === category, the button gets the active CSS class for visual highlighting.
  • This component has zero internal state. The parent is the single source of truth for which category is selected.
  • Save CategoryFilter.jsx.

You won't see a change in the browser yet. You'll wire this component into App.jsx shortly.

Create the SortControls component

The SortControls component follows the same pattern as CategoryFilter. It renders a <select> dropdown whose value is controlled by the parent through props. This makes it a controlled component.

  • Create a new file at src/components/SortControls.jsx and paste the following code:
function SortControls({ sortOption, onSortChange }) {
  return (
    <div className="sort-controls">
      <label htmlFor="sort-select">Sort by: </label>
      {/* Controlled select: value and onChange come from the parent */}
      <select
        id="sort-select"
        value={sortOption}
        onChange={(e) => onSortChange(e.target.value)}
      >
        <option value="default">Default</option>
        <option value="price-asc">Price: Low to High</option>
        <option value="price-desc">Price: High to Low</option>
        <option value="name-asc">Name: A-Z</option>
      </select>
    </div>
  );
}

export default SortControls;

What does this code do?

  • The value prop on the <select> is bound to sortOption from the parent. React keeps the dropdown's displayed value in sync with the parent's state at all times.
  • When the user picks a new option, onChange fires and calls onSortChange with the option's value string (like price-asc). The parent receives this string and uses it to sort the product list.
  • Save SortControls.jsx.

You won't see a change in the browser yet. Both new components need to be imported and rendered in App.jsx before they appear.

Why do CategoryFilter and SortControls have no internal state?

Both components are "dumb" presentational components. They receive data and callbacks through props, and the parent (App.jsx) owns the actual state.

This is the lifting state up pattern. Because App owns the search term, the selected category, and the sort option, it can combine all three in one place to produce the final filtered list. If each component owned its own state, App would have no way to coordinate them.

Wire category filtering and sorting into App.jsx

Now you'll connect both new components to App.jsx and build the filtering pipeline. The pipeline chains three operations: filter by search term, filter by category, then sort.

  • In App.jsx, replace everything above the return statement (from the imports down through the filtering logic) with the following code:
import { useState, useCallback } from "react";
import products from "./data/products";
import SearchBar from "./components/SearchBar";
import CategoryFilter from "./components/CategoryFilter";
import SortControls from "./components/SortControls";
import ProductCard from "./components/ProductCard";
import "./App.css";

// Extract unique category names from the product data
const categories = [...new Set(products.map((p) => p.category))];

function App() {
  const [searchTerm, setSearchTerm] = useState("");
  // Track which category button is active ("All" = no filter)
  const [selectedCategory, setSelectedCategory] = useState("All");
  // Track the selected sort option
  const [sortOption, setSortOption] = useState("default");

  const handleSearch = useCallback((term) => {
    setSearchTerm(term);
  }, []);

  // Step 1: Filter by search term
  let result = products.filter((product) =>
    product.name.toLowerCase().includes(searchTerm.toLowerCase())
  );

  // Step 2: Filter by category (skip when "All" is selected)
  if (selectedCategory !== "All") {
    result = result.filter((product) => product.category === selectedCategory);
  }

  // Step 3: Copy with .slice() so .sort() doesn't mutate the filtered array
  const filteredProducts = result.slice();

  // Step 4: Sort the copy based on the selected option
  if (sortOption === "price-asc") {
    filteredProducts.sort((a, b) => a.price - b.price);
  } else if (sortOption === "price-desc") {
    filteredProducts.sort((a, b) => b.price - a.price);
  } else if (sortOption === "name-asc") {
    filteredProducts.sort((a, b) => a.name.localeCompare(b.name));
  }

What changed in this code?

  • Two new imports bring in CategoryFilter and SortControls.
  • The categories constant sits outside the component. It uses new Set() to extract unique category names from the products array. Since the product data never changes, this only runs once when the module loads.
  • Two new useState calls track selectedCategory (defaults to "All") and sortOption (defaults to "default").
  • The old single-line const filteredProducts = products.filter(...) is now a 4-step pipeline. It filters by search, filters by category, copies with .slice(), then sorts the copy.

Why call .slice() before .sort()?

JavaScript's .sort() method mutates the array in place. If you called .sort() directly on result, you'd be rearranging the same array that .filter() returned. Calling .slice() first creates a shallow copy, so the sort only touches the copy.

This is the immutable update pattern. It prevents subtle bugs where mutating shared data causes unexpected re-renders or stale values. Interviewers love asking about this.

  • Save App.jsx.

You won't see a change in the browser yet. The new components still need to be added to the JSX return.

  • Now replace the entire return (...) block in App.jsx with the following code:
  return (
    <div className="app">
      <header className="app__header">
        <h1>Product Catalog</h1>
        <p>{filteredProducts.length} products found</p>
      </header>
      <div className="app__controls">
        <SearchBar onSearch={handleSearch} />
        {/* Pass categories, the active selection, and the setter to CategoryFilter */}
        <CategoryFilter
          categories={categories}
          selected={selectedCategory}
          onSelect={setSelectedCategory}
        />
        {/* Pass the current sort option and setter to SortControls */}
        <SortControls sortOption={sortOption} onSortChange={setSortOption} />
      </div>
      <div className="app__grid">
        {/* Show products if any match, otherwise show an empty message */}
        {filteredProducts.length > 0 ? (
          filteredProducts.map((product) => (
            <ProductCard key={product.id} product={product} />
          ))
        ) : (
          <p className="app__empty">
            No products match your search. Try a different term or category.
          </p>
        )}
      </div>
    </div>
  );

What changed in the JSX?

  • CategoryFilter receives the categories array, the selectedCategory state, and setSelectedCategory as the onSelect callback. When the user clicks a category button, the state updates in App and triggers a re-render.
  • SortControls receives sortOption and setSortOption as onSortChange. Same pattern.
  • The grid now has an empty state. If filteredProducts.length is 0, it shows a message instead of rendering nothing.
  • Save App.jsx and check your browser at http://localhost:5173.
  • You should see category buttons (All, Electronics, Clothing, Books, Home) and a "Sort by" dropdown below the search bar.
  • Click the Electronics button. The grid should shrink to only show electronics products, and the "products found" count should update.
  • Select Price: Low to High from the sort dropdown. The products should reorder by price.
  • Type a search term while a category filter is active. All three controls should work together.

Controls not showing up or not working?

  • If you see a blank page or errors in the browser console, check that your import paths match exactly: ./components/CategoryFilter and ./components/SortControls. File names are case-sensitive.
  • If clicking category buttons does nothing, make sure you passed setSelectedCategory as the onSelect prop on <CategoryFilter>.
  • If sorting doesn't work, check that the old const filteredProducts = products.filter(...) line is fully replaced. You should see let result = products.filter(...) instead.
  • help me debug why my category filter or sort controls aren't working in App.jsx

✔️ Awesome, I've got everything!

Make sure you've saved App.jsx.

ⓧ I'd like to double check the full code

import { useState, useCallback } from "react";
import products from "./data/products";
import SearchBar from "./components/SearchBar";
import CategoryFilter from "./components/CategoryFilter";
import SortControls from "./components/SortControls";
import ProductCard from "./components/ProductCard";
import "./App.css";

const categories = [...new Set(products.map((p) => p.category))];

function App() {
  const [searchTerm, setSearchTerm] = useState("");
  const [selectedCategory, setSelectedCategory] = useState("All");
  const [sortOption, setSortOption] = useState("default");

  const handleSearch = useCallback((term) => {
    setSearchTerm(term);
  }, []);

  let result = products.filter((product) =>
    product.name.toLowerCase().includes(searchTerm.toLowerCase())
  );

  if (selectedCategory !== "All") {
    result = result.filter((product) => product.category === selectedCategory);
  }

  const filteredProducts = result.slice();

  if (sortOption === "price-asc") {
    filteredProducts.sort((a, b) => a.price - b.price);
  } else if (sortOption === "price-desc") {
    filteredProducts.sort((a, b) => b.price - a.price);
  } else if (sortOption === "name-asc") {
    filteredProducts.sort((a, b) => a.name.localeCompare(b.name));
  }

  return (
    <div className="app">
      <header className="app__header">
        <h1>Product Catalog</h1>
        <p>{filteredProducts.length} products found</p>
      </header>
      <div className="app__controls">
        <SearchBar onSearch={handleSearch} />
        <CategoryFilter
          categories={categories}
          selected={selectedCategory}
          onSelect={setSelectedCategory}
        />
        <SortControls sortOption={sortOption} onSortChange={setSortOption} />
      </div>
      <div className="app__grid">
        {filteredProducts.length > 0 ? (
          filteredProducts.map((product) => (
            <ProductCard key={product.id} product={product} />
          ))
        ) : (
          <p className="app__empty">
            No products match your search. Try a different term or category.
          </p>
        )}
      </div>
    </div>
  );
}

export default App;

Your dashboard now has search, category filtering, and sorting all working together. Next up, you'll optimize the filtering pipeline with useMemo and build a custom hook that saves filter preferences to localStorage.

Optimize with useMemo and Build a useLocalStorage Hook

Your search, category filter, and sort controls all work together. Every time the app re-renders, though, the entire filtering and sorting computation runs again even if none of the inputs changed.

In this step, you'll wrap that computation in useMemo so React only recalculates when it needs to. You'll also build a custom useLocalStorage hook so filter preferences survive a page refresh.

In this step, get ready to:
  • Wrap the filter-and-sort computation in useMemo for memoization.
  • Build a custom useLocalStorage hook from scratch.
  • Persist category and sort preferences in the browser's localStorage.
Wrap the filter-and-sort computation in useMemo

Right now, the filtering and sorting logic runs on every single render of your App component. With useMemo, React caches the result and only recomputes when a dependency actually changes.

  • In src/App.jsx, find the import line at the top:
import { useState, useCallback } from "react";
  • Add useMemo to the import so the line looks like this:
import { useState, useMemo, useCallback } from "react";

Now you need to wrap the existing computation in a useMemo call.

  • Find the block of code below the handleSearch function that starts with let result = products.filter:
  let result = products.filter((product) =>
    product.name.toLowerCase().includes(searchTerm.toLowerCase())
  );

  if (selectedCategory !== "All") {
    result = result.filter((product) => product.category === selectedCategory);
  }

  const filteredProducts = result.slice();

  if (sortOption === "price-asc") {
    filteredProducts.sort((a, b) => a.price - b.price);
  } else if (sortOption === "price-desc") {
    filteredProducts.sort((a, b) => b.price - a.price);
  } else if (sortOption === "name-asc") {
    filteredProducts.sort((a, b) => a.name.localeCompare(b.name));
  }
  • Replace that entire block with the following useMemo version:
  const filteredProducts = useMemo(() => {
    console.log("Recalculating filtered products...");

    // Filter by search term
    let result = products.filter((product) =>
      product.name.toLowerCase().includes(searchTerm.toLowerCase())
    );

    // Filter by selected category
    if (selectedCategory !== "All") {
      result = result.filter(
        (product) => product.category === selectedCategory
      );
    }

    // Create a copy before sorting to avoid mutating the filtered array
    const sorted = result.slice();

    // Sort based on the selected sort option
    if (sortOption === "price-asc") {
      sorted.sort((a, b) => a.price - b.price);
    } else if (sortOption === "price-desc") {
      sorted.sort((a, b) => b.price - a.price);
    } else if (sortOption === "name-asc") {
      sorted.sort((a, b) => a.name.localeCompare(b.name));
    }

    return sorted;
  }, [searchTerm, selectedCategory, sortOption]);

What does this code do?

useMemo takes two arguments: a function that computes a value, and a dependency array.

  • React runs the function on the first render and caches the result.
  • On subsequent renders, React compares each dependency using Object.is. If nothing changed, it returns the cached result without recomputing.
  • The console.log inside the callback lets you see exactly when recalculation happens vs when the cache is used.

Why useMemo for 16 products?

For a 16-item dataset, the performance gain is negligible. The real value here is learning the pattern and the vocabulary.

In interviews, you'll be asked when memoization helps and when it adds overhead. Building it on a small dataset lets you observe the caching behavior clearly in the console before you ever need it at scale.

  • Save src/App.jsx.
  • Open your browser's DevTools console (Cmd+Option+J on macOS, Ctrl+Shift+J on Windows).
  • You should see Recalculating filtered products... logged in the console.
  • Type a letter in the search box. After a brief delay, a new Recalculating filtered products... message appears as the debounced value triggers a recalculation.

Not seeing the console log?

  • Make sure you saved src/App.jsx. Unsaved changes won't appear in the browser.
  • Check that the console.log is inside the useMemo callback, before the first let result line.
  • If you see an error about useMemo is not defined, double check your import line includes useMemo.

Help me debug my useMemo setup

Build the useLocalStorage hook

useState stores values in memory, so they disappear on every page refresh. Your second custom hook will fix that by syncing state with the browser's localStorage.

  • Create a new file at src/hooks/useLocalStorage.js (inside the same hooks folder where useDebounce.js lives).
  • Add the following code to the file:
import { useState, useEffect } from "react";

function useLocalStorage(key, initialValue) {
  // Initialize state with value from localStorage, or fall back to initialValue
  const [storedValue, setStoredValue] = useState(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      console.error("Error reading localStorage key \"" + key + "\":", error);
      return initialValue;
    }
  });

  // Write to localStorage whenever storedValue changes
  useEffect(() => {
    try {
      window.localStorage.setItem(key, JSON.stringify(storedValue));
    } catch (error) {
      console.error("Error setting localStorage key \"" + key + "\":", error);
    }
  }, [key, storedValue]);

  return [storedValue, setStoredValue];
}

export default useLocalStorage;

What does this hook do?

  • useState(() => ...) uses a lazy initializer. The arrow function only runs on the very first render, reading any existing value from localStorage.
  • The try/catch blocks protect against corrupted or missing data. If JSON.parse fails, the hook falls back to the initial value instead of crashing.
  • The useEffect writes back to localStorage whenever the stored value changes, keeping the two in sync.
  • It returns [storedValue, setStoredValue] just like useState, so it's a drop-in replacement.
  • Save src/hooks/useLocalStorage.js.

You won't see a change in the browser yet. The next substep wires this hook into App.

✔️ Awesome, I've got everything!

Great. Double check you've saved the file before moving on.

ⓧ I'd like to double check the full code

import { useState, useEffect } from "react";

function useLocalStorage(key, initialValue) {
  const [storedValue, setStoredValue] = useState(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      console.error("Error reading localStorage key \"" + key + "\":", error);
      return initialValue;
    }
  });

  useEffect(() => {
    try {
      window.localStorage.setItem(key, JSON.stringify(storedValue));
    } catch (error) {
      console.error("Error setting localStorage key \"" + key + "\":", error);
    }
  }, [key, storedValue]);

  return [storedValue, setStoredValue];
}

export default useLocalStorage;
Wire useLocalStorage into App.jsx and add global styles

Now swap out the two useState calls for useLocalStorage. Because the hook has the same return shape, the rest of your component stays untouched.

  • In src/App.jsx, find the import block at the top. Add this new import line below the products import:
import useLocalStorage from "./hooks/useLocalStorage";
  • Next, find the two useState lines for selectedCategory and sortOption:
  const [selectedCategory, setSelectedCategory] = useState("All");
  const [sortOption, setSortOption] = useState("default");
  • Replace both lines with these useLocalStorage calls:
  const [selectedCategory, setSelectedCategory] = useLocalStorage(
    "catalog-category",
    "All"
  );
  const [sortOption, setSortOption] = useLocalStorage(
    "catalog-sort",
    "default"
  );

What changed?

The first argument to useLocalStorage is the key name used in the browser's localStorage. The second argument is the fallback value if nothing is stored yet.

Because useLocalStorage returns [value, setValue] just like useState, the rest of App.jsx needs zero changes. This is the power of the custom hook extraction pattern.

Before testing, let's add the global styles so your app layout looks polished.

  • Open src/App.css and replace its contents with the following styles:
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
    sans-serif;
  background: #f5f5f5;
  color: #333;
}

.app {
  max-width: 960px;
  margin: 0 auto;
  padding: 24px 16px;
}

.app__header {
  margin-bottom: 24px;
}

.app__header h1 {
  font-size: 1.8rem;
  margin-bottom: 4px;
}

.app__header p {
  color: #666;
  font-size: 0.9rem;
}

.app__controls {
  display: flex;
  flex-direction: column;
  gap: 12px;
  margin-bottom: 24px;
}

.search-bar input {
  width: 100%;
  padding: 10px 14px;
  font-size: 1rem;
  border: 1px solid #ddd;
  border-radius: 6px;
  outline: none;
}

.search-bar input:focus {
  border-color: #1a73e8;
  box-shadow: 0 0 0 2px rgba(26, 115, 232, 0.2);
}

.category-filter {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
}

.category-filter button {
  padding: 6px 14px;
  font-size: 0.85rem;
  border: 1px solid #ddd;
  border-radius: 20px;
  background: #fff;
  cursor: pointer;
  transition: all 0.15s ease;
}

.category-filter button:hover {
  border-color: #1a73e8;
  color: #1a73e8;
}

.category-filter button.active {
  background: #1a73e8;
  color: #fff;
  border-color: #1a73e8;
}

.sort-controls {
  display: flex;
  align-items: center;
  gap: 8px;
}

.sort-controls label {
  font-size: 0.9rem;
  color: #555;
}

.sort-controls select {
  padding: 6px 10px;
  font-size: 0.9rem;
  border: 1px solid #ddd;
  border-radius: 6px;
  background: #fff;
  cursor: pointer;
}

.app__grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
  gap: 16px;
}

.app__empty {
  grid-column: 1 / -1;
  text-align: center;
  color: #888;
  padding: 40px 0;
  font-size: 1rem;
}

What do these styles do?

  • The global reset (* { margin: 0 }) removes default browser spacing so everything aligns consistently.
  • .app__grid uses CSS Grid with auto-fill to create a responsive product grid that wraps cards automatically.
  • The category filter buttons get pill-shaped styling with an .active class that highlights the selected category in blue.
  • Save both src/App.jsx and src/App.css.
  • Refresh your browser. Your product grid should now have clean spacing, styled controls, and pill-shaped category buttons.
  • Click the Electronics category button to filter the products.
  • Refresh the page. The Electronics filter should still be selected after the refresh.
  • Open DevTools, go to the Application tab, and expand Local Storage in the left sidebar.
  • Click on http://localhost:5173. You should see catalog-category and catalog-sort listed as keys with their current values.

Filters not persisting after refresh?

  • Make sure you replaced useState with useLocalStorage for both selectedCategory and sortOption. If either still uses useState, that value resets on refresh.
  • Check that the import line import useLocalStorage from "./hooks/useLocalStorage" is present. A missing import causes a runtime error.
  • Verify the key strings match exactly: "catalog-category" and "catalog-sort". A typo in the key means the hook reads from an empty slot on refresh.

Help me debug my useLocalStorage hook

✔️ Awesome, I've got everything!

Great. Double check you've saved both files before moving on.

ⓧ I'd like to double check the full code

Here is the complete src/App.jsx:

import { useState, useMemo, useCallback } from "react";
import products from "./data/products";
import useLocalStorage from "./hooks/useLocalStorage";
import SearchBar from "./components/SearchBar";
import CategoryFilter from "./components/CategoryFilter";
import SortControls from "./components/SortControls";
import ProductCard from "./components/ProductCard";
import "./App.css";

const categories = [...new Set(products.map((p) => p.category))];

function App() {
  const [searchTerm, setSearchTerm] = useState("");
  const [selectedCategory, setSelectedCategory] = useLocalStorage(
    "catalog-category",
    "All"
  );
  const [sortOption, setSortOption] = useLocalStorage(
    "catalog-sort",
    "default"
  );

  const handleSearch = useCallback((term) => {
    setSearchTerm(term);
  }, []);

  const filteredProducts = useMemo(() => {
    console.log("Recalculating filtered products...");

    let result = products.filter((product) =>
      product.name.toLowerCase().includes(searchTerm.toLowerCase())
    );

    if (selectedCategory !== "All") {
      result = result.filter(
        (product) => product.category === selectedCategory
      );
    }

    const sorted = result.slice();

    if (sortOption === "price-asc") {
      sorted.sort((a, b) => a.price - b.price);
    } else if (sortOption === "price-desc") {
      sorted.sort((a, b) => b.price - a.price);
    } else if (sortOption === "name-asc") {
      sorted.sort((a, b) => a.name.localeCompare(b.name));
    }

    return sorted;
  }, [searchTerm, selectedCategory, sortOption]);

  return (
    <div className="app">
      <header className="app__header">
        <h1>Product Catalog</h1>
        <p>{filteredProducts.length} products found</p>
      </header>
      <div className="app__controls">
        <SearchBar onSearch={handleSearch} />
        <CategoryFilter
          categories={categories}
          selected={selectedCategory}
          onSelect={setSelectedCategory}
        />
        <SortControls sortOption={sortOption} onSortChange={setSortOption} />
      </div>
      <div className="app__grid">
        {filteredProducts.length > 0 ? (
          filteredProducts.map((product) => (
            <ProductCard key={product.id} product={product} />
          ))
        ) : (
          <p className="app__empty">
            No products match your search. Try a different term or category.
          </p>
        )}
      </div>
    </div>
  );
}

export default App;

And the complete src/App.css:

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
    sans-serif;
  background: #f5f5f5;
  color: #333;
}

.app {
  max-width: 960px;
  margin: 0 auto;
  padding: 24px 16px;
}

.app__header {
  margin-bottom: 24px;
}

.app__header h1 {
  font-size: 1.8rem;
  margin-bottom: 4px;
}

.app__header p {
  color: #666;
  font-size: 0.9rem;
}

.app__controls {
  display: flex;
  flex-direction: column;
  gap: 12px;
  margin-bottom: 24px;
}

.search-bar input {
  width: 100%;
  padding: 10px 14px;
  font-size: 1rem;
  border: 1px solid #ddd;
  border-radius: 6px;
  outline: none;
}

.search-bar input:focus {
  border-color: #1a73e8;
  box-shadow: 0 0 0 2px rgba(26, 115, 232, 0.2);
}

.category-filter {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
}

.category-filter button {
  padding: 6px 14px;
  font-size: 0.85rem;
  border: 1px solid #ddd;
  border-radius: 20px;
  background: #fff;
  cursor: pointer;
  transition: all 0.15s ease;
}

.category-filter button:hover {
  border-color: #1a73e8;
  color: #1a73e8;
}

.category-filter button.active {
  background: #1a73e8;
  color: #fff;
  border-color: #1a73e8;
}

.sort-controls {
  display: flex;
  align-items: center;
  gap: 8px;
}

.sort-controls label {
  font-size: 0.9rem;
  color: #555;
}

.sort-controls select {
  padding: 6px 10px;
  font-size: 0.9rem;
  border: 1px solid #ddd;
  border-radius: 6px;
  background: #fff;
  cursor: pointer;
}

.app__grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
  gap: 16px;
}

.app__empty {
  grid-column: 1 / -1;
  text-align: center;
  color: #888;
  padding: 40px 0;
  font-size: 1rem;
}

Your dashboard now caches expensive computations and remembers user preferences across sessions. Next up, you'll take on a Secret Mission to add pagination with page reset logic.

Secret mission

Add Pagination

Your dashboard shows all 16 products at once. That works for now, but real catalogs have hundreds of items. Can you add pagination that displays 6 products per page, with Previous and Next buttons that stay in sync when filters change?

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely on your local machine, so there are no ongoing costs or cloud resources to worry about. Decide whether to keep your project files, pause development, or delete everything.

Resources you used:

  • The product-catalog project folder on your Desktop (including all source files and node_modules).
  • The Vite development server (running on localhost:5173).
  • Two localStorage entries (catalog-category and catalog-sort).

Keep everything running

No action needed. Choose this if you are still actively building or plan to extend the project with the Secret Mission.

  • Your Vite dev server stays running in the terminal. You can stop and restart it anytime with npm run dev.
  • Your localStorage entries persist in your browser until you clear them manually.

Pause - I'll come back to this later

Shut down the dev server to free up the terminal and the port, but keep all project files for later.

  • Go to the terminal where Vite is running and press Ctrl+C to stop the dev server.
  • Your product-catalog folder stays on your Desktop. When you are ready to resume, open the folder, run npm run dev, and pick up where you left off.

Delete - I don't want to use this again

Remove all project files and stored data to start completely fresh.

  • Go to the terminal where Vite is running and press Ctrl+C to stop the dev server.
  • Delete the entire product-catalog folder from your Desktop.
  • To clear the localStorage entries, open your browser's DevTools, go to the Application tab, select Local Storage, and delete the catalog-category and catalog-sort keys.

Nice Work!

Nice Work!

You just built a fully interactive Product Catalog Dashboard from an empty Vite scaffold, with zero AI help. Every hook, every filter, and every component came from your own hands.

You've learned how to:

  • Build custom React hooks (useDebounce and useLocalStorage) from scratch using useState, useEffect, and cleanup functions.
  • Compose reusable components (ProductCard, SearchBar, CategoryFilter, SortControls) using controlled components, lifting state up, and JavaScript array methods like .filter(), .sort(), and .slice().
  • Optimize rendering with useMemo for memoized derived state and persist user preferences with localStorage.
  • Secret Mission: Added pagination with page reset logic using useEffect.

Ready to quiz yourself?