React Product Catalog: Display Items with Filtering
A product catalog is the heart of any ecommerce store—it's where customers discover items, compare prices, and decide what to buy. Building an efficient, filterable product catalog in React requires fetching data from an API, managing filter state, and rendering product cards without blocking the UI. This article shows you how to create a catalog component that displays hundreds or thousands of products with real-time category and price filtering. You'll learn to fetch data with axios, cache results to reduce API calls, and use React hooks to keep filters in sync with the URL so users can share filtered views.
Designing Your Product API Schema
Before you write React code, design your backend API to return structured product data. A typical product object looks like:
{
"id": "prod_12345",
"name": "Wireless Headphones",
"description": "Noise-cancelling over-ear headphones",
"price": 129.99,
"originalPrice": 179.99,
"category": "electronics",
"rating": 4.5,
"reviewCount": 234,
"imageUrl": "https://api.store.com/images/prod_12345.jpg",
"inStock": true,
"tags": ["audio", "wireless", "travel"]
}
Your API endpoint should support filtering. A GET request like /api/products?category=electronics&maxPrice=150 lets the frontend reduce the dataset server-side, improving performance for large catalogs. This is better than fetching all 10,000 products and filtering in the browser—your API does the heavy lifting.
Create a Custom Hook to Fetch Products
Separate your data-fetching logic from your components with a custom hook. Create src/hooks/useFetch.js:
import { useEffect, useState } from 'react';
import axios from 'axios';
export function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let isMounted = true;
const fetch = async () => {
try {
const response = await axios.get(url);
if (isMounted) {
setData(response.data);
setError(null);
}
} catch (err) {
if (isMounted) {
setError(err.message);
setData(null);
}
} finally {
if (isMounted) setLoading(false);
}
};
fetch();
return () => {
isMounted = false;
};
}, [url]);
return { data, loading, error };
}
This hook handles the common fetch patterns: loading state (for spinners), error state (for alerts), and the cleanup flag isMounted to prevent memory leaks if the component unmounts before the request finishes. The hook re-fetches whenever the URL changes—perfect for applying filters.
Build the ProductCard Component
Create src/components/ProductCard.jsx:
import { Link } from 'react-router-dom';
export default function ProductCard({ product }) {
const discount = product.originalPrice
? Math.round(
((product.originalPrice - product.price) / product.originalPrice) * 100
)
: 0;
return (
<Link to={`/products/${product.id}`}>
<div className="bg-white rounded-lg shadow hover:shadow-lg transition p-4">
<img
src={product.imageUrl}
alt={product.name}
className="w-full h-48 object-cover rounded mb-4"
/>
{discount > 0 && (
<div className="absolute top-2 right-2 bg-red-500 text-white px-2 py-1 rounded text-sm font-bold">
{discount}% OFF
</div>
)}
<h3 className="text-lg font-semibold text-gray-800 truncate">
{product.name}
</h3>
<p className="text-sm text-gray-600 mb-3 line-clamp-2">
{product.description}
</p>
<div className="flex items-baseline gap-2 mb-2">
<span className="text-xl font-bold text-gray-900">
${product.price.toFixed(2)}
</span>
{product.originalPrice && (
<span className="text-sm text-gray-500 line-through">
${product.originalPrice.toFixed(2)}
</span>
)}
</div>
<div className="flex items-center gap-1 mb-3">
<span className="text-yellow-500">★</span>
<span className="text-sm text-gray-700">
{product.rating} ({product.reviewCount} reviews)
</span>
</div>
<button
className={`w-full py-2 rounded font-semibold transition ${
product.inStock
? 'bg-blue-600 text-white hover:bg-blue-700'
: 'bg-gray-300 text-gray-500 cursor-not-allowed'
}`}
disabled={!product.inStock}
>
{product.inStock ? 'Add to Cart' : 'Out of Stock'}
</button>
</div>
</Link>
);
}
This card component displays price, discount badge, rating, and stock status. The Link wraps the entire card so clicking anywhere navigates to the product detail page.
Build the Catalog Page with Filters
Create src/pages/Products.jsx:
import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import ProductCard from '../components/ProductCard';
import { useFetch } from '../hooks/useFetch';
export default function Products() {
const [searchParams, setSearchParams] = useSearchParams();
const [filters, setFilters] = useState({
category: searchParams.get('category') || '',
minPrice: searchParams.get('minPrice') || '',
maxPrice: searchParams.get('maxPrice') || '',
search: searchParams.get('search') || '',
});
const queryString = new URLSearchParams(filters).toString();
const { data: products, loading, error } = useFetch(
`${import.meta.env.VITE_API_URL}/products?${queryString}`
);
const handleFilterChange = (e) => {
const { name, value } = e.target;
const newFilters = { ...filters, [name]: value };
setFilters(newFilters);
// Update URL so users can bookmark/share filtered views
setSearchParams(newFilters);
};
const categories = ['electronics', 'clothing', 'books', 'home'];
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-7xl mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-6">Our Products</h1>
{/* Filter Sidebar */}
<div className="grid grid-cols-4 gap-8">
<div className="col-span-1">
<div className="bg-white p-4 rounded-lg shadow">
<h2 className="font-semibold mb-4">Filters</h2>
{/* Category Filter */}
<div className="mb-6">
<label className="block text-sm font-medium mb-2">
Category
</label>
<select
name="category"
value={filters.category}
onChange={handleFilterChange}
className="w-full border rounded px-3 py-2 text-sm"
>
<option value="">All Categories</option>
{categories.map((cat) => (
<option key={cat} value={cat}>
{cat.charAt(0).toUpperCase() + cat.slice(1)}
</option>
))}
</select>
</div>
{/* Price Range Filter */}
<div>
<label className="block text-sm font-medium mb-2">
Price Range
</label>
<input
type="number"
name="minPrice"
placeholder="Min"
value={filters.minPrice}
onChange={handleFilterChange}
className="w-full border rounded px-3 py-2 text-sm mb-2"
/>
<input
type="number"
name="maxPrice"
placeholder="Max"
value={filters.maxPrice}
onChange={handleFilterChange}
className="w-full border rounded px-3 py-2 text-sm"
/>
</div>
</div>
</div>
{/* Products Grid */}
<div className="col-span-3">
{loading && <div className="text-center py-12">Loading...</div>}
{error && (
<div className="text-center py-12 text-red-600">
Error: {error}
</div>
)}
{products && (
<>
<p className="text-gray-600 mb-4">
Showing {products.length} products
</p>
<div className="grid grid-cols-3 gap-6">
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
</>
)}
</div>
</div>
</div>
</div>
);
}
This component manages filter state, reads filters from the URL (so users can bookmark or share filtered views), and updates the URL as filters change. The API URL is built dynamically from the filter values.
Pagination for Large Catalogs
For catalogs with thousands of products, load products in batches. Update your API call:
const page = searchParams.get('page') || 1;
const pageSize = 20;
const { data: result, loading, error } = useFetch(
`${import.meta.env.VITE_API_URL}/products?${queryString}&page=${page}&limit=${pageSize}`
);
const products = result?.items || [];
const totalPages = result?.totalPages || 1;
Then add pagination buttons at the bottom of your catalog.
Key Takeaways
- Design your API to support filter parameters server-side to reduce payload size and improve catalog performance.
- Use custom hooks like
useFetch()to separate data logic from UI components, making code testable and reusable. - Store filters in URL query parameters so users can share filtered views and the browser back button works intuitively.
- ProductCard components should be simple, showing key info (price, rating, stock) and linking to detail pages.
- Paginate large catalogs to avoid loading 10,000 products at once—fetch 20–50 per page.
Frequently Asked Questions
How do I search for products by name?
Add a search input that updates the search filter, then send it to your API: /products?search=headphones. Your backend performs full-text search on the product name and description fields.
Should I fetch all products upfront or lazy-load?
Lazy-load is better. Fetch the first page (20 items) when the page mounts, then load more when the user scrolls down or clicks "Next". This keeps your app responsive and reduces bandwidth.
How do I handle the "no products found" case?
Check if products.length === 0 after filters are applied, then show a friendly message: "No products match your filters. Try adjusting your search."
Can I cache filter results to avoid re-fetching?
Yes, wrap your API call in a cache using React Query or Zustand. For this series, the simple approach is to only re-fetch when filters change (which the dependency array already does).