Product Page Components Intermediate 12 min read
Product Page Data Architecture
Design the data architecture for React-powered product pages. Learn how to pass product data from Liquid, structure components, and manage variant state.
The product page is where purchasing decisions happen. It needs to display product information, handle variant selection, show media, and facilitate adding to cart. Let’s design a scalable architecture.
Product Page Layout
┌─────────────────────────────────────────────────────────────────────────┐│ PRODUCT PAGE ││ ││ ┌─────────────────────────────┐ ┌─────────────────────────────────┐ ││ │ │ │ │ ││ │ │ │ Breadcrumbs │ ││ │ Media Gallery │ │ Product Title │ ││ │ (React) │ │ Price │ ││ │ │ │ │ ││ │ ┌───┐ ┌───┐ ┌───┐ ┌───┐ │ │ ┌─────────────────────────┐ │ ││ │ │ │ │ │ │ │ │ │ │ │ │ Variant Selector │ │ ││ │ └───┘ └───┘ └───┘ └───┘ │ │ │ (React) │ │ ││ │ │ │ └─────────────────────────┘ │ ││ └─────────────────────────────┘ │ │ ││ │ ┌─────────────────────────┐ │ ││ │ │ Add to Cart Form │ │ ││ │ │ (React) │ │ ││ │ └─────────────────────────┘ │ ││ │ │ ││ │ Description │ ││ └─────────────────────────────────┘ ││ ││ ┌───────────────────────────────────────────────────────────────────┐ ││ │ Product Tabs (React) │ ││ └───────────────────────────────────────────────────────────────────┘ ││ ││ ┌───────────────────────────────────────────────────────────────────┐ ││ │ Recommendations Carousel (React) │ ││ └───────────────────────────────────────────────────────────────────┘ │└─────────────────────────────────────────────────────────────────────────┘Component Structure
src/components/product/├── ProductPage/│ ├── ProductPage.tsx│ └── index.ts├── MediaGallery/│ ├── MediaGallery.tsx│ ├── ThumbnailStrip.tsx│ ├── Lightbox.tsx│ └── index.ts├── VariantSelector/│ ├── VariantSelector.tsx│ ├── OptionGroup.tsx│ ├── ColorSwatch.tsx│ └── index.ts├── AddToCartForm/│ ├── AddToCartForm.tsx│ ├── QuantityInput.tsx│ └── index.ts├── ProductTabs/│ ├── ProductTabs.tsx│ ├── TabPanel.tsx│ └── index.ts├── Recommendations/│ ├── Recommendations.tsx│ └── index.ts└── index.tsTypeScript Types
/** * Represents a product image from Shopify. * Includes variant associations for variant-specific image display. */export interface ProductImage { id: number; // Unique image identifier. url: string; // Full URL to the image (typically at a specific width). alt: string; // Alt text for accessibility. width: number; // Original image width. height: number; // Original image height. variantIds: number[]; // IDs of variants associated with this image.}
/** * Represents a specific variant of a product. * Variants are unique combinations of options (e.g., "Blue / Large"). */export interface ProductVariant { id: number; // Unique variant identifier (used for cart operations). title: string; // Full variant title (e.g., "Blue / Large"). price: number; // Current price in cents. compareAtPrice: number | null; // Original price if on sale. available: boolean; // Whether this variant is in stock. sku: string | null; // Stock Keeping Unit for inventory. barcode: string | null; // Barcode for scanning. inventoryQuantity: number | null; // Current stock level (if tracked). options: string[]; // Selected option values (e.g., ["Blue", "Large"]). image: number | null; // Associated image ID for variant-specific display.}
/** * Represents a product option like Size or Color. * Contains all possible values for this option. */export interface ProductOption { name: string; // Option name (e.g., "Size", "Color"). position: number; // Display order (1-indexed). values: string[]; // All possible values (e.g., ["Small", "Medium", "Large"]).}
/** * Complete product data structure. * Contains all information needed to render a product page. */export interface Product { id: number; // Unique product identifier. title: string; // Product title. handle: string; // URL-friendly slug. description: string; // Plain text description. descriptionHtml: string; // HTML formatted description. vendor: string; // Brand or manufacturer. productType: string; // Product category. tags: string[]; // Tags for organization/filtering. price: number; // Default/first variant price. priceMin: number; // Lowest price across variants. priceMax: number; // Highest price across variants. compareAtPrice: number | null; // Default compare-at price. available: boolean; // Whether any variant is available. options: ProductOption[]; // Available options (Size, Color, etc.). variants: ProductVariant[]; // All product variants. images: ProductImage[]; // All product images. featuredImage: ProductImage | null; // Main display image. metafields: Record<string, unknown>; // Custom metafield data.}
/** * Data structure for the product page, including the initially selected variant. */export interface ProductPageData { product: Product; // Full product data. selectedVariantId: number | null; // Pre-selected variant (from URL or first available).}Liquid Data Serialization
{% comment %} sections/product-main.liquid {% endcomment %}
<div id="product-page-root" data-product-handle="{{ product.handle }}"></div>
<script type="application/json" id="product-data"> { "product": { "id": {{ product.id }}, "title": {{ product.title | json }}, "handle": {{ product.handle | json }}, "description": {{ product.description | json }}, "descriptionHtml": {{ product.description | json }}, "vendor": {{ product.vendor | json }}, "productType": {{ product.type | json }}, "tags": {{ product.tags | json }}, "price": {{ product.price }}, "priceMin": {{ product.price_min }}, "priceMax": {{ product.price_max }}, "compareAtPrice": {% if product.compare_at_price %}{{ product.compare_at_price }}{% else %}null{% endif %}, "available": {{ product.available | json }}, "options": [ {%- for option in product.options_with_values -%} { "name": {{ option.name | json }}, "position": {{ option.position }}, "values": {{ option.values | json }} }{%- unless forloop.last -%},{%- endunless -%} {%- endfor -%} ], "variants": [ {%- for variant in product.variants -%} { "id": {{ variant.id }}, "title": {{ variant.title | json }}, "price": {{ variant.price }}, "compareAtPrice": {% if variant.compare_at_price %}{{ variant.compare_at_price }}{% else %}null{% endif %}, "available": {{ variant.available | json }}, "sku": {{ variant.sku | json }}, "barcode": {{ variant.barcode | json }}, "inventoryQuantity": {% if variant.inventory_quantity %}{{ variant.inventory_quantity }}{% else %}null{% endif %}, "options": {{ variant.options | json }}, "image": {% if variant.image %}{{ variant.image.id }}{% else %}null{% endif %} }{%- unless forloop.last -%},{%- endunless -%} {%- endfor -%} ], "images": [ {%- for image in product.images -%} { "id": {{ image.id }}, "url": {{ image | image_url: width: 1200 | json }}, "alt": {{ image.alt | json }}, "width": {{ image.width }}, "height": {{ image.height }}, "variantIds": {{ image.variants | map: 'id' | json }} }{%- unless forloop.last -%},{%- endunless -%} {%- endfor -%} ], "featuredImage": {% if product.featured_image %} { "id": {{ product.featured_image.id }}, "url": {{ product.featured_image | image_url: width: 1200 | json }}, "alt": {{ product.featured_image.alt | json }}, "width": {{ product.featured_image.width }}, "height": {{ product.featured_image.height }}, "variantIds": [] } {% else %}null{% endif %}, "metafields": { "specifications": {{ product.metafields.custom.specifications | json }}, "sizeGuide": {{ product.metafields.custom.size_guide | json }}, "careInstructions": {{ product.metafields.custom.care_instructions | json }} } }, "selectedVariantId": {% if product.selected_or_first_available_variant %}{{ product.selected_or_first_available_variant.id }}{% else %}null{% endif %} }</script>Product State with Custom Hook
import { useState, useMemo, useCallback, useEffect } from 'react';import type { Product, ProductVariant, ProductOption } from '@/types/product';
/** * Interface for the product state hook return value. * Combines state properties with actions for managing product selection. */interface ProductState { selectedOptions: Record<string, string>; // Current option selections (e.g., { Size: "M", Color: "Blue" }). selectedVariant: ProductVariant | null; // The variant matching current selections. quantity: number; // Quantity to add to cart. isAvailable: boolean; // Whether the selected variant is in stock. price: number; // Current variant price. compareAtPrice: number | null; // Compare-at price for sale display. currentImageId: number | null; // Currently displayed image ID.
// Actions selectOption: (optionName: string, value: string) => void; // Update an option selection. setQuantity: (quantity: number) => void; // Update quantity. setCurrentImage: (imageId: number) => void; // Change displayed image. isOptionAvailable: (optionName: string, value: string) => boolean; // Check option availability.}
/** * Custom hook for managing product page state. * Handles variant selection, availability checking, URL syncing, and image updates. */export function useProductState( product: Product, initialVariantId: number | null): ProductState { // Initialize selected options from initial variant. // Priority: URL variant > first available variant > first variant. const [selectedOptions, setSelectedOptions] = useState<Record<string, string>>(() => { const variant = initialVariantId ? product.variants.find((productVariant) => productVariant.id === initialVariantId) : product.variants.find((productVariant) => productVariant.available) || product.variants[0];
if (!variant) return {};
// Build options map from variant's option values. return product.options.reduce((accumulator, option, index) => { accumulator[option.name] = variant.options[index]; return accumulator; }, {} as Record<string, string>); });
const [quantity, setQuantityState] = useState(1); const [currentImageId, setCurrentImageId] = useState<number | null>( product.featuredImage?.id || null );
// Memoized variant lookup based on current option selections. // Re-calculates only when product or selectedOptions change. const selectedVariant = useMemo(() => { return product.variants.find((variant) => product.options.every( (option, index) => variant.options[index] === selectedOptions[option.name] ) ) || null; }, [product, selectedOptions]);
// Sync selected variant ID to URL for shareable links. useEffect(() => { if (selectedVariant) { const url = new URL(window.location.href); url.searchParams.set('variant', String(selectedVariant.id)); window.history.replaceState({}, '', url.toString()); } }, [selectedVariant]);
// Auto-update displayed image when variant changes (if variant has specific image). useEffect(() => { if (selectedVariant?.image) { setCurrentImageId(selectedVariant.image); } }, [selectedVariant]);
// Update a single option value. const selectOption = useCallback((optionName: string, value: string) => { setSelectedOptions((current) => ({ ...current, [optionName]: value, })); }, []);
// Set quantity with minimum validation. const setQuantity = useCallback((newQuantity: number) => { if (newQuantity >= 1) { setQuantityState(newQuantity); } }, []);
// Check if selecting a specific option value would result in an available variant. // Used to disable/strikethrough unavailable option combinations. const isOptionAvailable = useCallback( (optionName: string, value: string) => { // Create hypothetical options by changing only the specified option. const hypotheticalOptions = { ...selectedOptions, [optionName]: value }; // Check if any available variant matches these hypothetical options. return product.variants.some( (variant) => variant.available && product.options.every( (option, index) => variant.options[index] === hypotheticalOptions[option.name] ) ); }, [product, selectedOptions] );
return { selectedOptions, selectedVariant, quantity, isAvailable: selectedVariant?.available ?? false, price: selectedVariant?.price ?? product.price, compareAtPrice: selectedVariant?.compareAtPrice ?? product.compareAtPrice, currentImageId, selectOption, setQuantity, setCurrentImage: setCurrentImageId, isOptionAvailable, };}Main Product Page Component
import { useMemo } from 'react';import { readJsonScript } from '@/utils/data-bridge'; // Utility to parse JSON from script tags.import { useProductState } from '@/hooks/useProductState'; // Custom hook for product state management.import type { ProductPageData } from '@/types/product';import { MediaGallery } from '../MediaGallery';import { VariantSelector } from '../VariantSelector';import { AddToCartForm } from '../AddToCartForm';import { ProductTabs } from '../ProductTabs';import { Recommendations } from '../Recommendations';import { formatMoney } from '@/utils/money';import styles from './ProductPage.module.css';
/** * Main ProductPage component that orchestrates all product page elements. * Reads product data from Liquid-rendered JSON and manages state via custom hook. */export function ProductPage() { // Read product data from the JSON script tag rendered by Liquid. const pageData = readJsonScript('product-data') as ProductPageData | null;
// Handle missing data gracefully. if (!pageData) { return <div>Failed to load product data</div>; }
const { product, selectedVariantId } = pageData; // Initialize product state with the custom hook. const productState = useProductState(product, selectedVariantId);
// Determine if product is on sale. const hasDiscount = productState.compareAtPrice && productState.compareAtPrice > productState.price;
return ( <div className={styles.page}> {/* Main product section: gallery + info side by side */} <div className={styles.main}> {/* Media gallery with thumbnails and lightbox */} <MediaGallery images={product.images} currentImageId={productState.currentImageId} onImageSelect={productState.setCurrentImage} productTitle={product.title} />
{/* Product information column */} <div className={styles.info}> <h1 className={styles.title}>{product.title}</h1>
{/* Price display with sale styling */} <div className={styles.price}> <span className={hasDiscount ? styles.salePrice : ''}> {formatMoney(productState.price)} </span> {hasDiscount && ( <span className={styles.comparePrice}> {formatMoney(productState.compareAtPrice!)} </span> )} </div>
{/* Variant selector for Size, Color, etc. */} <VariantSelector options={product.options} selectedOptions={productState.selectedOptions} onSelectOption={productState.selectOption} isOptionAvailable={productState.isOptionAvailable} />
{/* Add to cart form with quantity input */} <AddToCartForm variant={productState.selectedVariant} quantity={productState.quantity} onQuantityChange={productState.setQuantity} isAvailable={productState.isAvailable} />
{/* Product description - rendered as HTML */} <div className={styles.description} dangerouslySetInnerHTML={{ __html: product.descriptionHtml }} /> </div> </div>
{/* Tabbed content: description, specs, care, etc. */} <ProductTabs product={product} />
{/* Related products carousel */} <Recommendations productId={product.id} /> </div> );}Key Takeaways
- Single source of truth: Product state hook manages all selection logic
- URL synchronization: Keep variant ID in URL for sharing
- Image-variant linking: Switch images when variant changes
- Availability checking: Disable unavailable option combinations
- Metafields for content: Use metafields for tabs content
- Component isolation: Each product section is independent
In the next lesson, we’ll build the Media Gallery with zoom and lightbox.
Finished this lesson?
Mark it complete to track your progress.
Discussion
Loading comments...