Build a React Product Catalog

Build a React catalog that loads, filters, sorts, and paginates API data.

Introduction

30 Second Summary

A product list only helps when it reflects what is actually available. Stale cards leave visitors guessing after the data changes.

In this project, you will build a React product catalog one small feature at a time. You will learn how JSX, props, state, Effects, and API requests work together before adding filtering, sorting, and pagination.

What You'll Build

You will open a responsive catalog, watch it load products from your local API, recover from an outage, and update its results as you change the browsing controls.

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

  • A reusable product card that receives product details through props and renders them with JSX.
  • A responsive product grid that creates one card per record and adapts from one column to three.
  • A live API connection with visible loading, empty, error, retry, filtering, sorting, and pagination states.
  • Secret Mission: Open a selected product in a details overlay without losing the current catalog page.

Are there any prerequisites?

You need basic JavaScript, including functions, objects, arrays, and array methods. You do not need to feel confident with React yet.

You also need the NextWork Product Catalog API running locally at http://localhost:5000 with at least two saved products. The project checks the API before the frontend depends on it.

Before We Start

Before you write code, connect the finished catalog to the React skills you are about to practise. This gives every later step a clear purpose.

Set Up the React Frontend

Your catalog needs a development environment that can understand JSX and generate Tailwind styles. Vite provides that fast feedback loop in the browser.

This step sets up React and Tailwind CSS, then proves they work with one small page. You will see the result before building any catalog features.

In this step, get ready to:
  • Check that Node.js can run the current Vite release.
  • Create the frontend and install its dependencies.
  • Display a Tailwind-styled React page.
Check Node.js and npm

Node.js runs Vite on your computer. Vite 8 requires Node.js 20.19+ or 22.12+, so checking first prevents a confusing setup failure.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open system search.
  • Type Visual Studio Code and press Enter.
  • Click Terminal in the top menu bar.
  • Click New Terminal.
  • Check both installed versions by running:
node --version
npm --version

✔️ I see a supported version

Continue when Node.js reports 20.19+ or 22.12+. An npm version number confirms the package manager is ready.

ⓧ I see an older version

Your terminal found Node.js, but the version is too old for this Vite release. Install the current LTS release before continuing.

  • Open the official Node.js download page.
  • Install Node.js v24.21.0 LTS for your operating system.
  • Restart Visual Studio Code after the installer finishes.
  • Run both version checks again.

ⓧ Command not found

The terminal cannot find Node.js. Install the current LTS release before creating the project.

  • Open the official Node.js download page.
  • Install Node.js v24.21.0 LTS for your operating system.
  • Restart Visual Studio Code after the installer finishes.
  • Run both version checks again.
Create the Vite project

The React template creates the entry files and development scripts. Installing the pinned packages keeps your results consistent with this project.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create the React project by running this command:
npm create vite@latest product-catalog-frontend -- --template react

Seeing an installation prompt?

The first run may ask permission to install create-vite. Enter y to continue.

  • Move into the new folder and install the pinned packages by running:
cd product-catalog-frontend
npm install react@19.3.0 react-dom@19.3.0
npm install -D vite@8.3.0 @vitejs/plugin-react@6.1.1 tailwindcss@4.3.3 @tailwindcss/vite@4.3.3

The download usually takes about a minute. The terminal prompt returns when npm finishes.

  • Confirm the project files exist by running:
ls

You should see package.json, src, and vite.config.js in the output.

Installation failed?

If installation fails, check that the terminal is inside product-catalog-frontend and that your internet connection is active. Help me diagnose the installation.

Connect Tailwind and see the result

Tailwind 4 uses a Vite plugin plus one CSS import. The plugin reads utility names from your JSX and generates the matching styles.

  • Replace the contents of vite.config.js with this configuration:
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";

// Transform JSX and generate the Tailwind utilities used by the app.
export default defineConfig({
  plugins: [react(), tailwindcss()],
});

What does this configuration do?

  • The React plugin transforms JSX into code the browser can run.
  • The Tailwind plugin generates CSS for the utility classes found in your components.
  • Save vite.config.js.
  • Replace the contents of src/index.css with this import:
@import "tailwindcss";
  • Save src/index.css.
  • Start the development server by running:
npm run dev

The server keeps this terminal busy until you press Ctrl+C. Open the local URL printed in the terminal.

  • Replace the contents of src/App.jsx with this first component:
export default function App() {
  // Show visible proof that React and Tailwind work together.
  return (
    <main className="min-h-screen bg-slate-100 p-8 text-slate-900">
      <section className="mx-auto max-w-4xl rounded-3xl bg-indigo-700 p-10 text-white shadow-xl">
        <p className="text-sm font-bold uppercase tracking-widest text-indigo-200">NextWork frontend</p>
        <h1 className="mt-2 text-4xl font-black">Product Catalog</h1>
        <p className="mt-3 text-indigo-100">React and Tailwind are ready.</p>
      </section>
    </main>
  );
}

What should this prove?

React renders the component structure. Tailwind turns the utility names into the indigo panel, spacing, rounded corners, and typography you see.

  • Save src/App.jsx.
  • Return to the running page in your browser.

You should see a light page with a rounded indigo panel titled Product Catalog. That visible styling proves the complete toolchain works.

Tailwind styles missing?

Confirm that src/main.jsx imports ./index.css and that tailwindcss() remains in the Vite plugin array. Help me find the missing Tailwind connection.

Your React and Tailwind toolchain now produces a visible page. Next, you will learn how JSX and props turn one product record into a reusable card.

Build the Catalog with JSX

Build a Product Card with JSX

Your setup page proves the browser can render React and Tailwind styles. A catalog now needs a repeatable card instead of one fixed panel.

This step introduces JSX, components, and props through one product card. You will change the data after it renders so you can see which code controls the structure and which code controls the content.

In this step, get ready to:
  • Create a reusable product-card component.
  • Pass one product object into the component.
  • Change a prop and observe the card update.
Understand JSX and props

How does a component receive data?

JSX lets a JavaScript function describe interface elements with HTML-like tags. A React component returns that JSX so the same structure can be rendered more than once.

A prop carries data from a parent component into a child component. The product card receives one product object, then reads its fields inside braces.

  • Select the src folder in the Explorer sidebar.
  • Click the New File icon in the Explorer toolbar.
  • Enter ProductCard.jsx as the file name.
  • Create the reusable card by pasting:
export default function ProductCard({ product }) {
  // Read this card's content from the product prop.
  return (
    <article className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
      <span className="rounded-full bg-indigo-50 px-3 py-1 text-xs font-bold text-indigo-700">
        {product.category}
      </span>
      <h2 className="mt-4 text-2xl font-bold">{product.name}</h2>
      <p className="mt-2 text-slate-600">{product.description}</p>
      <p className="mt-4 font-black">Price: {product.price}</p>
    </article>
  );
}

What does this component do?

  • The function name starts with a capital letter so React treats it as a component.
  • The braces around product.name switch from JSX markup to a JavaScript value.
  • The Tailwind utilities style the same card structure regardless of which product it receives.
Render the first product

The parent component owns the product data. Passing that object through the product prop connects the data to the reusable card.

  • Save src/ProductCard.jsx.
  • Replace the contents of src/App.jsx with this parent component:
import ProductCard from "./ProductCard.jsx";

export default function App() {
  // Keep the sample data in the parent that renders the card.
  const product = {
    name: "Wireless headphones",
    category: "electronics",
    description: "A sample record for learning how props move data.",
    price: 79.99,
  };

  return (
    <main className="min-h-screen bg-slate-100 p-8 text-slate-900">
      <section className="mx-auto max-w-xl">
        <h1 className="mb-6 text-4xl font-black">Product Catalog</h1>
        <ProductCard product={product} />
      </section>
    </main>
  );
}

How does the parent connect to the card?

  • The import makes ProductCard available inside App.
  • The product object groups the fields that belong to one record.
  • The product={product} prop sends that object into the child component.
  • Save src/App.jsx.
  • Return to the running page in your browser.

You should see one white card named Wireless headphones beneath the catalog heading. The category, description, and price all come from the parent component.

Card not rendering?

Check that the import path ends with ./ProductCard.jsx and that the exported component uses the same spelling. Help me find the component error.

Prove how props control content

The card owns the layout, while the parent owns the values. A small data change should update the visible text without changing ProductCard.jsx.

  • In src/App.jsx, find this line:
    name: "Wireless headphones",
  • Replace the product name so the line reads:
    name: "Studio headphones",
  • Save src/App.jsx.
  • Check the card in your browser.

You should see Studio headphones without changing the card component. That update proves props separate reusable structure from changing data.

You now have one reusable card and a clear data path into it. Next, you will use an array and map() to render a responsive grid of products.

Render a Responsive Product Grid

Your first product card now separates its reusable layout from the data supplied by App. A real catalog needs that same pattern for every product in a collection.

This step turns an array into a grid with map(). Responsive Tailwind breakpoints then change the number of columns without changing the product data.

In this step, get ready to:
  • Store several sample products in one array.
  • Render one card for each product with map().
  • Test the grid at narrow and wide browser widths.
Create the product collection

An array keeps related product objects in an ordered collection. Each object needs a stable _id so React can track the matching card between renders.

  • Select the src folder in the Explorer sidebar.
  • Click the New File icon in the Explorer toolbar.
  • Enter sampleProducts.js as the file name.
  • Add three sample records by pasting:
// Supply several records before the live API replaces them.
export const sampleProducts = [
  {
    _id: "sample-1",
    name: "Studio headphones",
    category: "electronics",
    description: "Closed-back headphones for focused listening.",
    price: 79.99,
  },
  {
    _id: "sample-2",
    name: "Desk lamp",
    category: "home",
    description: "An adjustable lamp for a compact workspace.",
    price: 34.5,
  },
  {
    _id: "sample-3",
    name: "Travel mug",
    category: "kitchen",
    description: "An insulated mug with a locking lid.",
    price: 22,
  },
];

Why do the objects share a shape?

Each object has the same field names because every object feeds the same card component. The values differ, which gives each rendered card its own content.

Render one card per product

The map() method visits each array item and returns one JSX element. React uses each element's key to match it with the correct product.

  • Save src/sampleProducts.js.
  • Replace the contents of src/App.jsx with this grid component:
import ProductCard from "./ProductCard.jsx";
import { sampleProducts } from "./sampleProducts.js";

export default function App() {
  // Create one reusable card for every product record.
  return (
    <main className="min-h-screen bg-slate-100 p-8 text-slate-900">
      <div className="mx-auto max-w-6xl">
        <header className="mb-6">
          <h1 className="text-4xl font-black">Product Catalog</h1>
          <p className="mt-2 text-slate-600">Browse the current product collection.</p>
        </header>
        <section className="grid grid-cols-1 gap-5 md:grid-cols-2 lg:grid-cols-3">
          {sampleProducts.map((product) => (
            <ProductCard key={product._id} product={product} />
          ))}
        </section>
      </div>
    </main>
  );
}

How does the array become a grid?

  • The import moves sample data out of the interface component.
  • The callback receives one product during each visit to the array.
  • The returned ProductCard receives that product through its prop.
  • The key uses the record's stable identifier instead of its array position.
  • Save src/App.jsx.
  • Return to the running catalog in your browser.

You should see three cards with different names, categories, descriptions, and prices. The shared layout proves one component can render many records.

Missing one or more cards?

Confirm that sampleProducts is imported with braces because it is a named export. Check that every object has a unique _id. Help me debug the product grid.

Test the responsive breakpoints

The grid starts with one column. The md: and lg: prefixes apply wider layouts only when the browser reaches those breakpoints.

Before resizing, how many columns do you expect on a narrow window?

  • Drag the right edge of the browser inward until the page is narrow.
  • Expand the browser until it fills most of the screen.

You should see one column on the narrow window. Wider windows should show two columns and then three columns as space becomes available.

Your catalog can now turn any array of product objects into a responsive grid. Next, you will replace the sample array with records from the live API.

Replace Static Data with the API

Your responsive grid now renders every object in a local array. The card structure works, but changes in the backend cannot reach the browser.

This step follows one product request from the REST API into React state. You will inspect the response first, then connect it to the grid with an Effect.

In this step, get ready to:
  • Inspect the product-list response in the browser.
  • Create a reusable request function with the Fetch API.
  • Load the returned products into React state.
Inspect the API response

The frontend needs to know the response shape before it can render the data. The product endpoint returns the records in data and paging information in meta.

  • Start the configured backend from its own terminal by running:
npm run dev

Keep this backend terminal running separately from the Vite terminal.

  • Open a new browser tab.
  • Enter http://localhost:5000/api/v1/products in the address bar.

You should see JSON containing success, data, and meta. Each object inside data represents one product.

Product endpoint unavailable?

Confirm that the backend command runs in the existing API repository, not in product-catalog-frontend. Help me check the local API.

Create the product request

The browser's Fetch API returns a Promise for an HTTP response. An unsuccessful HTTP status does not reject automatically, so the request checks response.ok before returning data.

  • Select the src folder in the Explorer sidebar.
  • Click the New File icon in the Explorer toolbar.
  • Enter api.js as the file name.
  • Add the product request by pasting:
const API_BASE_URL = "http://localhost:5000/api/v1";

export async function getProducts() {
  // Request the public collection and reject unsuccessful HTTP responses.
  const response = await fetch(`${API_BASE_URL}/products`);
  const payload = await response.json();

  if (!response.ok) {
    throw new Error(payload.message || `Request failed with status ${response.status}`);
  }

  return payload;
}

How does the request function work?

  • The base URL keeps the shared API address in one place.
  • The first await pauses until the HTTP response arrives.
  • The second await reads the response body as JSON.
  • The function returns the complete payload so the caller can use both data and meta later.
Load products into state

State stores data that can change while the page is open. An Effect synchronizes the component with the API after the first render, then the state update causes React to render the live products.

  • Save src/api.js.
  • Replace the contents of src/App.jsx with this live-data component:
import { useEffect, useState } from "react";
import { getProducts } from "./api.js";
import ProductCard from "./ProductCard.jsx";

export default function App() {
  const [products, setProducts] = useState([]);

  // Fetch once, then ignore a response if this Effect has been cleaned up.
  useEffect(() => {
    let ignore = false;
    getProducts().then((payload) => {
      if (!ignore) setProducts(payload.data);
    });
    return () => { ignore = true; };
  }, []);

  return (
    <main className="min-h-screen bg-slate-100 p-8 text-slate-900">
      <div className="mx-auto max-w-6xl">
        <h1 className="mb-6 text-4xl font-black">Product Catalog</h1>
        <section className="grid grid-cols-1 gap-5 md:grid-cols-2 lg:grid-cols-3">
          {products.map((product) => (
            <ProductCard key={product._id} product={product} />
          ))}
        </section>
      </div>
    </main>
  );
}

How does the Effect update the grid?

  • The initial empty array matches the collection shape before the request finishes.
  • The empty dependency array runs the Effect after the component first renders.
  • The state update replaces the empty array with payload.data.
  • The cleanup flag prevents an outdated response from updating an abandoned Effect.

Before saving, which product names do you expect the API to place in the grid?

  • Save src/App.jsx.
  • Return to the running frontend.

You should see cards containing the names and categories from the API response. The three sample records are gone because the grid now reads React state.

Live cards missing?

Check that the backend still runs on port 5000 and that the Effect stores payload.data rather than the full payload. Help me trace the missing products.

Test the success-only version

The current Effect only describes success. Stopping the backend exposes the missing failure experience before you build it.

  • Stop the backend server by pressing Ctrl+C in its terminal.
  • Return to the frontend in your browser.

Before reloading, do you expect the cards or an explanation to remain visible?

  • Reload the frontend once.

You should see the catalog heading with no cards and no explanation. The rejected request has no visible state yet, which is the gap you will fix next.

Your grid now uses live backend records, and you have seen why success-only fetching is incomplete. Next, you will make loading, failure, empty results, and recovery visible.

Handle Real API Conditions

Your catalog now receives live products, but the stopped backend leaves visitors with an unexplained empty grid. The interface needs to describe every stage of the request.

This step gives the request an explicit status: loading, success, or error. Those values let React choose the right message, grid, or recovery action.

In this step, get ready to:
  • Track the product request with state.
  • Render loading, empty, error, and retry outcomes.
  • Add an image fallback and verify outage recovery.
Model the request lifecycle

Several state values belong to the same request. A small custom hook keeps that request logic together while the component focuses on what the visitor sees.

  • Select the src folder in the Explorer sidebar.
  • Click the New File icon in the Explorer toolbar.
  • Enter useProducts.js as the file name.
  • Add the request-state hook by pasting:
import { useEffect, useState } from "react";
import { getProducts } from "./api.js";

export function useProducts() {
  const [products, setProducts] = useState([]);
  const [status, setStatus] = useState("loading");
  const [error, setError] = useState("");
  const [retryCount, setRetryCount] = useState(0);

  useEffect(() => {
    let ignore = false;
    async function loadProducts() {
      setStatus("loading");
      setError("");
      try {
        const payload = await getProducts();
        if (!ignore) setProducts(payload.data);
        if (!ignore) setStatus("success");
      } catch (requestError) {
        if (!ignore) setProducts([]);
        if (!ignore) setError(requestError.message || "Could not reach the API");
        if (!ignore) setStatus("error");
      }
    }
    loadProducts();
    return () => { ignore = true; };
  }, [retryCount]);

  return { products, status, error, retry: () => setRetryCount((count) => count + 1) };
}

How does the request state change?

  • The hook starts in loading because the first request has not finished.
  • A successful request stores the products before changing the status to success.
  • A failed request clears stale products and stores a useful message.
  • The retry function changes a dependency, which runs the Effect again.
  • Save src/useProducts.js.
  • Replace the contents of src/App.jsx with this request-aware component:
import ProductCard from "./ProductCard.jsx";
import { useProducts } from "./useProducts.js";

export default function App() {
  const { products, status, error, retry } = useProducts();

  return (
    <main className="min-h-screen bg-slate-100 p-8 text-slate-900">
      <h1 className="mx-auto mb-6 max-w-6xl text-4xl font-black">Product Catalog</h1>
      {status === "loading" && <p className="mx-auto max-w-6xl rounded-2xl bg-white p-8">Loading products...</p>}
      {status === "error" && (
        <section className="mx-auto max-w-6xl rounded-2xl border border-rose-200 bg-rose-50 p-8 text-rose-800">
          <h2 className="text-2xl font-bold">Could not reach the API</h2>
          <p className="mt-2">{error}</p>
          <button className="mt-4 rounded-xl bg-indigo-600 px-4 py-3 font-bold text-white" onClick={retry}>Try again</button>
        </section>
      )}
      {status === "success" && products.length === 0 && <p className="mx-auto max-w-6xl rounded-2xl bg-white p-8">No products found.</p>}
      {status === "success" && products.length > 0 && (
        <section className="mx-auto grid max-w-6xl grid-cols-1 gap-5 md:grid-cols-2 lg:grid-cols-3">
          {products.map((product) => <ProductCard key={product._id} product={product} />)}
        </section>
      )}
    </main>
  );
}
  • Save src/App.jsx.
  • Return to the frontend while the backend remains stopped.

You should see a pale red error panel with a Try again button. The page now explains the failed request instead of showing unexplained empty space.

Status panel missing?

Check that useProducts is imported with braces and that every status string uses the same spelling. Help me check the request states.

Recover with a fresh request

The retry button does not reload the whole page. It changes retryCount, which tells the Effect to make a fresh request with the current component still mounted.

  • Restart the backend from its terminal by running:
npm run dev

Before clicking retry, which status should appear first while the new request is running?

  • Click Try again in the browser.

You should briefly see Loading products... before the live cards return. The error message disappears because the hook clears it at the start of each request.

Add the image fallback

Product image links can be empty or broken even when the product request succeeds. Local image state lets each card hide a failed image without changing the other cards.

  • Replace the contents of src/ProductCard.jsx with this image-aware component:
import { useState } from "react";

export default function ProductCard({ product }) {
  const [showImage, setShowImage] = useState(Boolean(product.imageUrl));

  return (
    <article className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm">
      {showImage ? (
        <img className="h-48 w-full object-cover" src={product.imageUrl} alt={product.name} onError={() => setShowImage(false)} />
      ) : (
        <div className="grid h-48 place-items-center bg-slate-200 font-bold text-slate-500">No image</div>
      )}
      <div className="p-6">
        <span className="rounded-full bg-indigo-50 px-3 py-1 text-xs font-bold text-indigo-700">{product.category}</span>
        <h2 className="mt-4 text-2xl font-bold">{product.name}</h2>
        <p className="mt-2 text-slate-600">{product.description}</p>
        <p className="mt-4 font-black">Price: {product.price}</p>
      </div>
    </article>
  );
}

Why does each card own image state?

  • The initial Boolean records whether the product provides an image URL.
  • The image error handler changes only that card's local state.
  • Conditional JSX replaces a missing or failed image with the No image panel.
  • Save src/ProductCard.jsx.
  • Return to the running catalog.

Products with working image URLs should show their images. Products without a usable image should show a grey No image panel while the rest of the catalog remains available.

Broken image icon still visible?

Confirm that useState is imported and that the image uses onError={() => setShowImage(false). Help me debug the image fallback.

Your catalog now explains the complete request lifecycle and recovers without a page reload. Next, you will let visitors choose which products the API returns.

Add Filtering, Sorting, and Pagination

Add Filtering and Sorting

Your catalog now communicates request progress, failure, and recovery. Every successful request still returns the same unfiltered collection.

This step connects controlled React inputs to the API's category and sort query parameters. You will keep typed text separate from the applied filter so the catalog does not request data after every keystroke.

In this step, get ready to:
  • Build API URLs from category and sort values.
  • Create controlled filtering and sorting inputs.
  • Reload the grid when an applied value changes.
Build the query string

A query string carries browsing options after the ? in a URL. URLSearchParams safely formats those names and values before the request is sent.

  • Replace the contents of src/api.js with this parameter-aware request module:
const API_BASE_URL = "http://localhost:5000/api/v1";

async function request(path) {
  // Give every endpoint the same JSON and HTTP error handling.
  const response = await fetch(`${API_BASE_URL}${path}`);
  const payload = await response.json();
  if (!response.ok) throw new Error(payload.message || `Request failed with status ${response.status}`);
  return payload;
}

export function getProducts({ category = "", sort = "-createdAt", page = 1, limit = 6 } = {}) {
  // Convert the current browsing state into query parameters.
  const params = new URLSearchParams();
  params.set("sort", sort);
  params.set("page", String(page));
  params.set("limit", String(limit));
  if (category) params.set("category", category);
  return request(`/products?${params.toString()}`);
}

How does the request builder work?

  • The shared request() function keeps JSON and error handling consistent.
  • Default values make an empty call return the newest products on page one.
  • The category parameter is omitted when its value is empty.
  • The final URL contains only values supported by the backend.
  • In src/useProducts.js, find the hook declaration:
export function useProducts() {
  • Replace it with a declaration that accepts browsing options:
export function useProducts({ category = "", sort = "-createdAt", page = 1 } = {}) {
  • Find the current product request:
        const payload = await getProducts();
  • Pass the browsing options into the request:
        const payload = await getProducts({ category, sort, page });
  • Find the Effect dependency list:
  }, [retryCount]);
  • Add every request input to the dependency list:
  }, [category, sort, page, retryCount]);
  • Save src/api.js.
  • Save src/useProducts.js.
  • Check the Vite terminal for a successful update with no compilation error.

Hook stopped compiling?

Compare the function parameter names with the values passed to getProducts(). Check that the dependency list uses the same three names. Help me check the query-state edits.

Create the controlled inputs

A controlled input displays a value from state and reports every edit through a callback. The parent decides when a draft category becomes an applied category.

  • Create CatalogControls.jsx inside the src folder.
  • Add the category and sort controls by pasting:
export default function CatalogControls({ draft, category, sort, onDraft, onApply, onClear, onSort }) {
  // Keep input values controlled by the parent component.
  return (
    <form className="mb-6 grid gap-4 rounded-2xl bg-white p-4 shadow-sm md:grid-cols-2" onSubmit={onApply}>
      <label className="grid gap-2 font-bold">
        Exact category
        <input className="rounded-xl border border-slate-300 px-3 py-2 font-normal" value={draft} onChange={(event) => onDraft(event.target.value)} placeholder="For example, electronics" />
      </label>
      <label className="grid gap-2 font-bold">
        Sort products
        <select className="rounded-xl border border-slate-300 px-3 py-2 font-normal" value={sort} onChange={(event) => onSort(event.target.value)}>
          <option value="-createdAt">Newest first</option>
          <option value="createdAt">Oldest first</option>
          <option value="price">Price: low to high</option>
          <option value="-price">Price: high to low</option>
        </select>
      </label>
      <div className="flex gap-2">
        <button className="rounded-xl bg-indigo-600 px-4 py-3 font-bold text-white" type="submit">Apply category</button>
        {category && <button className="rounded-xl bg-slate-200 px-4 py-3 font-bold" type="button" onClick={onClear}>Clear</button>}
      </div>
    </form>
  );
}

How do controlled inputs work?

  • The input displays draft while the visitor types.
  • Submitting the form calls the parent without reloading the browser.
  • The select sends a backend-supported sort identifier to the parent.
  • The clear button appears only while a category is applied.
Connect the controls to the request

The parent stores both draft and applied values. Only the applied category enters useProducts, which keeps typing separate from requesting.

  • Save src/CatalogControls.jsx.
  • Replace the contents of src/App.jsx with this controlled catalog:
import { useState } from "react";
import CatalogControls from "./CatalogControls.jsx";
import ProductCard from "./ProductCard.jsx";
import { useProducts } from "./useProducts.js";

export default function App() {
  const [draft, setDraft] = useState("");
  const [category, setCategory] = useState("");
  const [sort, setSort] = useState("-createdAt");
  const { products, status, error, retry } = useProducts({ category, sort });
  const applyCategory = (event) => { event.preventDefault(); setCategory(draft.trim()); };
  const clearCategory = () => { setDraft(""); setCategory(""); };

  return (
    <main className="min-h-screen bg-slate-100 p-8 text-slate-900">
      <h1 className="mx-auto mb-6 max-w-6xl text-4xl font-black">Product Catalog</h1>
      <div className="mx-auto max-w-6xl"><CatalogControls draft={draft} category={category} sort={sort} onDraft={setDraft} onApply={applyCategory} onClear={clearCategory} onSort={setSort} /></div>
      {status === "loading" && <p className="mx-auto max-w-6xl rounded-2xl bg-white p-8">Loading products...</p>}
      {status === "error" && <section className="mx-auto max-w-6xl rounded-2xl bg-rose-50 p-8 text-rose-800"><p>{error}</p><button className="mt-4 rounded-xl bg-indigo-600 px-4 py-3 font-bold text-white" onClick={retry}>Try again</button></section>}
      {status === "success" && products.length === 0 && <p className="mx-auto max-w-6xl rounded-2xl bg-white p-8">No products found.</p>}
      {status === "success" && products.length > 0 && <section className="mx-auto grid max-w-6xl grid-cols-1 gap-5 md:grid-cols-2 lg:grid-cols-3">{products.map((product) => <ProductCard key={product._id} product={product} />)}</section>}
    </main>
  );
}

Before testing, do you expect typing alone or submitting the form to change the cards?

  • Save src/App.jsx.
  • Enter a category shown on one card into the Exact category field.
  • Click Apply category.
  • Select Price: low to high from the sort menu.

You should see only products from the applied category. Their order should update after you select the price sort.

Results unchanged?

Use the category text exactly as it appears on a card. Confirm that category and sort appear in the hook's dependency list. Help me trace the control state.

Your controls now change the exact request sent to the backend. Next, you will use the response metadata to move through the collection one page at a time.

Paginate the Results

Your category and sort controls now change the backend query and refresh the matching cards. A larger result set still needs a clear way to move beyond the first response page.

This step uses the API's meta object to show the total and current page. Previous and next buttons then update the page state that already feeds your request hook.

In this step, get ready to:
  • Store the response metadata beside the products.
  • Create bounded previous and next controls.
  • Reset pagination when filtering or sorting changes.
Keep the response metadata

The API returns total, page, limit, and pages inside meta. Storing that object lets the interface use the server's view of the collection.

  • In src/useProducts.js, add metadata state below the product state:
  const [meta, setMeta] = useState({ total: 0, page: 1, limit: 1, pages: 0 });
  • Find the successful product update:
        if (!ignore) setProducts(payload.data);
  • Add the metadata update directly below it:
        if (!ignore) setProducts(payload.data);
        if (!ignore) setMeta(payload.meta);
  • Find the product request and set a one-product page size for this exercise:
        const payload = await getProducts({ category, sort, page, limit: 1 });
  • Find the object returned by the hook:
  return { products, status, error, retry: () => setRetryCount((count) => count + 1) };
  • Expose the metadata with the other request values:
  return { products, meta, status, error, retry: () => setRetryCount((count) => count + 1) };
  • Save src/useProducts.js.
  • Check the Vite terminal for a successful update with no compilation error.

Why use one product per page?

The page size is deliberately 1 so two saved products are enough to practise both page buttons. You can increase the limit after you verify the behavior.

Build the pagination component

The server decides whether earlier or later pages exist. The buttons compare the current page with those boundaries before requesting another page.

  • Create Pagination.jsx inside the src folder.
  • Add the page controls by pasting:
export default function Pagination({ meta, onPage }) {
  // Keep requests inside the page range reported by the API.
  const onFirstPage = meta.page <= 1;
  const onLastPage = meta.pages === 0 || meta.page >= meta.pages;

  return (
    <nav className="mt-6 flex items-center justify-center gap-4" aria-label="Product pages">
      <button className="rounded-xl bg-slate-200 px-4 py-3 font-bold disabled:opacity-40" disabled={onFirstPage} onClick={() => onPage(meta.page - 1)}>
        Previous
      </button>
      <span>Page {meta.page} of {Math.max(meta.pages, 1)}</span>
      <button className="rounded-xl bg-slate-200 px-4 py-3 font-bold disabled:opacity-40" disabled={onLastPage} onClick={() => onPage(meta.page + 1)}>
        Next
      </button>
    </nav>
  );
}

How do the boundaries work?

  • The current page comes from the API response rather than a guessed value.
  • The previous button is disabled on page one.
  • The next button is disabled when the current page reaches the reported page count.
  • The callback asks the parent to store the next valid page.
Connect page state to the catalog

Page state belongs in App because both the request hook and pagination component need it. Filtering and sorting return to page one because the previous page number may not exist in the new result set.

  • Save src/Pagination.jsx.
  • Replace the contents of src/App.jsx with this paginated catalog:
import { useState } from "react";
import CatalogControls from "./CatalogControls.jsx";
import Pagination from "./Pagination.jsx";
import ProductCard from "./ProductCard.jsx";
import { useProducts } from "./useProducts.js";

export default function App() {
  const [draft, setDraft] = useState("");
  const [category, setCategory] = useState("");
  const [sort, setSort] = useState("-createdAt");
  const [page, setPage] = useState(1);
  const { products, meta, status, error, retry } = useProducts({ category, sort, page });
  const applyCategory = (event) => { event.preventDefault(); setCategory(draft.trim()); setPage(1); };
  const clearCategory = () => { setDraft(""); setCategory(""); setPage(1); };
  const changeSort = (value) => { setSort(value); setPage(1); };

  return (
    <main className="min-h-screen bg-slate-100 p-8 text-slate-900">
      <h1 className="mx-auto mb-6 max-w-6xl text-4xl font-black">Product Catalog</h1>
      <div className="mx-auto max-w-6xl"><CatalogControls draft={draft} category={category} sort={sort} onDraft={setDraft} onApply={applyCategory} onClear={clearCategory} onSort={changeSort} /></div>
      {status === "loading" && <p className="mx-auto max-w-6xl rounded-2xl bg-white p-8">Loading products...</p>}
      {status === "error" && <section className="mx-auto max-w-6xl rounded-2xl bg-rose-50 p-8 text-rose-800"><p>{error}</p><button className="mt-4 rounded-xl bg-indigo-600 px-4 py-3 font-bold text-white" onClick={retry}>Try again</button></section>}
      {status === "success" && products.length === 0 && <p className="mx-auto max-w-6xl rounded-2xl bg-white p-8">No products found.</p>}
      {status === "success" && products.length > 0 && <><p className="mx-auto mb-4 max-w-6xl font-bold">{meta.total} product(s)</p><section className="mx-auto grid max-w-6xl grid-cols-1 gap-5">{products.map((product) => <ProductCard key={product._id} product={product} />)}</section><Pagination meta={meta} onPage={setPage} /></>}
    </main>
  );
}

Before clicking a page button, which button should be disabled on page one?

  • Save src/App.jsx.
  • Check the total and page label above and below the card.
  • Click Next.
  • Click Previous to return to page one.

You should see a different product after moving forward. On page one, Previous should appear faded and remain unavailable.

Next button unavailable?

Confirm that the API contains at least two products and that the hook requests a limit of 1. Check that page appears in the Effect dependency list. Help me trace the page state.

Secret mission

Open a Product Details View

Select any product card and request its complete record from the single-product endpoint. Show the result in an overlay without resetting the current filter, sort order, or page.

Clean Up Your Resources

Clean Up Your Resources

Choose whether to keep, pause, or remove your local catalog resources. Your Vite frontend uses an existing Express backend on your computer, which means there are no ongoing cloud costs.

Resources you used:

  • Vite development server for the frontend catalog.
  • Existing Express development server that provides the catalog API requests.
  • Local product-catalog-frontend folder containing the complete frontend project.

Keep everything running

No action is needed. Choose this if you are still testing the catalog or want to continue the Secret Mission.

  • Leave the Vite development server running.
  • Leave the existing Express development server running.
  • Keep the product-catalog-frontend folder on your Desktop.

Both processes continue using some memory on your computer. They do not create an ongoing cloud cost.

Pause - I'll come back to this later

Stop both development processes to free up memory. Your source files remain on the Desktop for the next session.

  • Return to the frontend terminal.
  • Press Ctrl+C to stop Vite.
  • Return to the backend terminal.
  • Press Ctrl+C to stop Express.
  • Keep the product-catalog-frontend folder on your Desktop.

Your browser can no longer reach the local servers, but every project file remains ready to restart.

Delete - I don't want to use this again

Remove the frontend created during this project. The deletion is permanent, so confirm you no longer need the source files before continuing.

The existing backend repository stays untouched because it was created before this project.

  • Return to the frontend terminal.
  • Press Ctrl+C to stop Vite.
  • Return to the backend terminal.
  • Press Ctrl+C to stop Express.

macOS

Delete the product-catalog-frontend folder from your Desktop by running:

cd ~/Desktop
rm -rf product-catalog-frontend
ls

You should no longer see product-catalog-frontend in the output.

Folder still present?

Confirm both development servers are stopped. Check that the first command moved the terminal to your Desktop before running the deletion again. Help me check the macOS cleanup.

Windows

Delete the product-catalog-frontend folder from your Desktop by running:

Set-Location ~/Desktop
Remove-Item -Recurse -Force .\product-catalog-frontend
Get-ChildItem

You should no longer see product-catalog-frontend in the output.

Folder still present?

Confirm both development servers are stopped. Close editor tabs that still point into the frontend folder before running the deletion again. Help me check the Windows cleanup.

The frontend folder and both running processes are now removed. Your pre-existing backend files remain available for another project.

Nice Work!

Nice Work!

You did it! You built a React catalog from one JSX card to a live, resilient interface with filtering, sorting, and pagination.

What you learned:

  • Build reusable JSX components with props, render collections with map(), and adapt the grid with responsive Tailwind CSS utilities.
  • Connect the Fetch API to state and Effects, then make loading, empty, error, retry, and image-fallback outcomes visible.
  • Drive server-backed controls with controlled inputs, query parameters, response metadata, and bounded pagination.
  • Secret Mission: Request one selected product and show it in an overlay without resetting the current catalog controls.

Ready to quiz yourself?