Back to Machine Coding
Machine Coding
easy
mid

How would you 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.

5 min read·~45 min to think through

Closely related to [[design-the-frontend-for-a-product-listing-page-component-structure-state-data-fl]]. Implementation sketch here.

State layout

tsx
// 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

tsx
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

tsx
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:

tsx
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.
  • useDeferredValue on 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.

Senior engineer discussion

Seniors anchor on URL-as-state, image pipeline, and the focus / dialog semantics for cart and filter drawers.

Related questions