Build a product listing page with search, multi-select filters, and a cart in React.
URL-driven filter state (search, multi-select facets), debounced search input, server (or client) filter, virtualized grid, add-to-cart with optimistic UI, cart state in Zustand or Context. Components: `<Filters>`, `<ProductGrid>`, `<ProductCard>`, `<CartDrawer>`. Skeleton loading; result count announcement; accessible filter region.
Closely related to [[design-the-frontend-for-a-product-listing-page-component-structure-state-data-fl]]. Implementation sketch here.
State layout
// URL-driven filters
const [search, setSearch] = useSearchParam('q', '');
const [categories, setCategories] = useSearchParam('cat', [], parseArray);
const [minPrice, setMinPrice] = useSearchParam('min', 0, Number);
const debouncedSearch = useDebouncedValue(search, 250);
// Server query keyed on URL
const { data: products } = useQuery(
['products', debouncedSearch, categories, minPrice],
() => api.search({ q: debouncedSearch, categories, minPrice }),
);
// Cart in Zustand
const cart = useCart();Components
function ProductListingPage() {
return (
<div className="grid grid-cols-[240px_1fr] gap-6">
<Filters />
<main>
<Toolbar />
<ProductGrid />
</main>
<CartDrawer />
</div>
);
}
function Filters() {
const [categories, setCategories] = useSearchParam(...);
return (
<aside aria-label="Filters">
<fieldset>
<legend>Category</legend>
{CATS.map((c) => (
<label key={c.id}>
<input
type="checkbox"
checked={categories.includes(c.id)}
onChange={(e) =>
setCategories(e.target.checked
? [...categories, c.id]
: categories.filter((x) => x !== c.id),
)
}
/>
{c.label} ({c.count})
</label>
))}
</fieldset>
{/* price range, etc */}
</aside>
);
}
function ProductGrid() {
const { data, isLoading } = useProducts();
if (isLoading) return <GridSkeleton />;
if (!data?.length) return <Empty />;
return (
<div role="region" aria-label="Products">
<p aria-live="polite">{data.length} results</p>
<div className="grid grid-cols-3 gap-4">
{data.map((p) => <ProductCard key={p.id} product={p} />)}
</div>
</div>
);
}
function ProductCard({ product }) {
const addToCart = useCart((s) => s.add);
return (
<article>
<img src={product.image} alt={product.name} loading="lazy" />
<h3>{product.name}</h3>
<p>{formatPrice(product.price)}</p>
<button onClick={() => addToCart(product)}>Add to cart</button>
</article>
);
}Cart store
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export const useCart = create(persist((set, get) => ({
items: [] as CartItem[],
add: (p) => set((s) => {
const existing = s.items.find((i) => i.id === p.id);
return existing
? { items: s.items.map((i) => i.id === p.id ? { ...i, qty: i.qty + 1 } : i) }
: { items: [...s.items, { ...p, qty: 1 }] };
}),
remove: (id) => set((s) => ({ items: s.items.filter((i) => i.id !== id) })),
total: () => get().items.reduce((sum, i) => sum + i.qty * i.price, 0),
}), { name: 'cart' }));Persisted across reloads.
Optimistic add-to-cart
If add-to-cart hits a server, optimistically update the store and reconcile on response:
const add = (p) => {
const prev = get().items;
set({ items: optimistic(prev, p) });
api.addToCart(p.id).catch(() => set({ items: prev })); // rollback on error
};Performance
- Lazy-load images (
loading="lazy",srcset). - Virtualize the grid for huge catalogs.
- Server-side filter; only ship a window.
useDeferredValueon search for the filter computation if client-side.
A11y
- Filter sidebar is a
<aside aria-label="Filters">. - Filter group:
<fieldset><legend>. - Result count:
aria-live="polite". - Cart drawer: trap focus, restore on close,
role="dialog".
Interview framing
"URL is the source of truth for filters/search — shareable, back-button safe, SSR-ready. Debounced search → React Query refetch. Cart in Zustand with persist middleware. Components: Filters (fieldsets with checkboxes), ProductGrid (skeleton on load, lazy images), ProductCard (add-to-cart hits the cart store), CartDrawer (focus trap, dialog semantics). Optimistic add-to-cart with rollback on failure. Performance: virtualize for huge catalogs, lazy-load images, server-side filter. Announce result count via aria-live."
Follow-up questions
- •Why URL state over local component state?
- •How would you implement add-to-cart with offline support?
- •Walk me through the a11y of the filter sidebar.
Common mistakes
- •Filter state in component state — lost on back nav.
- •No skeletons.
- •Cart in component state — lost on reload.
- •Missing focus trap on cart drawer.
Performance considerations
- •Server filtering + virtualization for huge catalogs. Lazy images. Memoize cards if list updates frequently.
Edge cases
- •Empty filter combos.
- •URL-encoded weird values.
- •Mobile filter as drawer vs desktop sidebar.
Real-world examples
- •Shopify storefronts, Amazon, Algolia's storefront demo.