Product State: Variants, Availability, and Selection
Manage product state in React: selected options, variant matching, availability checking, and price updates. Build a complete product form state machine.
Product pages have complex state: selected options, the matching variant, availability, price, and quantity. In this lesson, we’ll build a robust product state solution.
Product State Requirements
A product form needs to track:
- Selected options: Which Size? Which Color?
- Current variant: The variant matching selected options
- Availability: Is the selected variant in stock?
- Price: Current and compare-at price
- Quantity: How many to add
- Media: Which image to show based on selection
The Product State Hook
Create a custom hook that encapsulates all product logic:
/* * Product State Hook * * Encapsulates all the logic for product variant selection: * - Tracks selected options (Size, Color, etc.) * - Finds the matching variant * - Checks availability * - Updates price * * This keeps component code clean and makes logic reusable. */import { useState, useMemo, useCallback } from 'react';import type { Product, ProductVariant, ProductOption } from '@/types';
interface ProductState { // Selected option values (e.g., { Size: "Medium", Color: "Blue" }) selectedOptions: Record<string, string>;
// The variant matching current selection (or null if invalid combination) selectedVariant: ProductVariant | null;
// Quantity to add quantity: number;
// Availability status isAvailable: boolean;
// Pricing (from selected variant) price: number; compareAtPrice: number | null;
// Actions selectOption: (optionName: string, value: string) => void; setQuantity: (quantity: number) => void;
// Helpers for UI (determine which options to disable/strike-through) isOptionAvailable: (optionName: string, value: string) => boolean; getVariantForOptions: (options: Record<string, string>) => ProductVariant | null;}
export function useProductState(product: Product): ProductState { // Initialize with the first available variant's options // Lazy initializer (function) only runs on first render const [selectedOptions, setSelectedOptions] = useState<Record<string, string>>(() => { // Prefer first available variant, fallback to first variant const initialVariant = product.variants.find((variant) => variant.available) || product.variants[0];
return buildOptionsFromVariant(product.options, initialVariant); });
const [quantity, setQuantityState] = useState(1);
// Find the variant matching current selection // useMemo: only recalculate when selectedOptions or variants change const selectedVariant = useMemo(() => { return findVariantByOptions(product.variants, selectedOptions); }, [product.variants, selectedOptions]);
// Derived state - computed from selectedVariant const isAvailable = selectedVariant?.available ?? false; const price = selectedVariant?.price ?? product.price; const compareAtPrice = selectedVariant?.compareAtPrice ?? product.compareAtPrice;
// Select an option value (e.g., select "Large" for "Size") // useCallback: stable reference for passing to child components const selectOption = useCallback((optionName: string, value: string) => { setSelectedOptions((current) => ({ ...current, [optionName]: value, })); }, []);
// Set quantity with min validation const setQuantity = useCallback((newQuantity: number) => { if (newQuantity < 1) return; // Don't allow 0 or negative setQuantityState(newQuantity); }, []);
// Check if selecting an option would lead to an available variant // Used to show strikethrough on sold-out options const isOptionAvailable = useCallback( (optionName: string, value: string) => { // Create hypothetical selection with this option changed const hypotheticalOptions = { ...selectedOptions, [optionName]: value, };
// Find if any variant matches and is available const variant = findVariantByOptions(product.variants, hypotheticalOptions); return variant?.available ?? false; }, [product.variants, selectedOptions] );
// Get variant for a specific option combination (utility) const getVariantForOptions = useCallback( (options: Record<string, string>) => { return findVariantByOptions(product.variants, options); }, [product.variants] );
return { selectedOptions, selectedVariant, quantity, isAvailable, price, compareAtPrice, selectOption, setQuantity, isOptionAvailable, getVariantForOptions, };}
// Helper: Build option map from a variant's option values// Converts variant.options array to { optionName: value } objectfunction buildOptionsFromVariant( options: ProductOption[], variant: ProductVariant): Record<string, string> { const result: Record<string, string> = {};
options.forEach((option, index) => { // options[0] = "Size", variant.options[0] = "Medium" result[option.name] = variant.options[index]; });
return result;}
// Helper: Find the variant that matches selected options// Returns null if no variant matches (shouldn't happen with valid data)function findVariantByOptions( variants: ProductVariant[], selectedOptions: Record<string, string>): ProductVariant | null { return ( variants.find((variant) => { // Every selected option value must match the variant's options return Object.entries(selectedOptions).every(([optionName, value], index) => { return variant.options[index] === value; }); }) ?? null );}Using the Product State Hook
Product Form Component
import { useProductState } from '@/hooks/useProductState';import { useCart } from '@/stores/cart';import type { Product } from '@/types';import { VariantSelector } from './VariantSelector';import { QuantityInput } from './QuantityInput';import { AddToCartButton } from './AddToCartButton';import { ProductPrice } from './ProductPrice';
interface ProductFormProps { product: Product;}
export function ProductForm({ product }: ProductFormProps) { const { selectedOptions, selectedVariant, quantity, isAvailable, price, compareAtPrice, selectOption, setQuantity, isOptionAvailable, } = useProductState(product);
const { addItem, isLoading } = useCart();
const handleAddToCart = async () => { if (!selectedVariant || !isAvailable) return;
await addItem(selectedVariant.id, quantity); };
return ( <div className="product-form"> <ProductPrice price={price} compareAtPrice={compareAtPrice} />
<VariantSelector options={product.options} selectedOptions={selectedOptions} onSelectOption={selectOption} isOptionAvailable={isOptionAvailable} />
<QuantityInput quantity={quantity} onChange={setQuantity} max={selectedVariant?.inventoryQuantity ?? 99} />
<AddToCartButton onClick={handleAddToCart} disabled={!selectedVariant || !isAvailable} loading={isLoading} available={isAvailable} />
{selectedVariant?.sku && <p className="product-sku">SKU: {selectedVariant.sku}</p>} </div> );}Variant Selector Component
import type { ProductOption } from '@/types';import styles from './VariantSelector.module.css';
interface VariantSelectorProps { options: ProductOption[]; selectedOptions: Record<string, string>; onSelectOption: (optionName: string, value: string) => void; isOptionAvailable: (optionName: string, value: string) => boolean;}
export function VariantSelector({ options, selectedOptions, onSelectOption, isOptionAvailable,}: VariantSelectorProps) { return ( <div className={styles.selector}> {options.map((option) => ( <OptionGroup key={option.name} option={option} selectedValue={selectedOptions[option.name]} onSelect={(value) => onSelectOption(option.name, value)} isValueAvailable={(value) => isOptionAvailable(option.name, value)} /> ))} </div> );}
interface OptionGroupProps { option: ProductOption; selectedValue: string; onSelect: (value: string) => void; isValueAvailable: (value: string) => boolean;}
function OptionGroup({ option, selectedValue, onSelect, isValueAvailable }: OptionGroupProps) { // Determine display type based on option name const isColorOption = option.name.toLowerCase() === 'color';
return ( <fieldset className={styles.optionGroup}> <legend className={styles.optionName}> {option.name}: <span className={styles.selectedValue}>{selectedValue}</span> </legend>
<div className={styles.optionValues}> {option.values.map((value) => { const isSelected = value === selectedValue; const isAvailable = isValueAvailable(value);
return ( <button key={value} type="button" className={` ${styles.optionButton} ${isSelected ? styles.selected : ''} ${!isAvailable ? styles.unavailable : ''} ${isColorOption ? styles.colorSwatch : ''} `} onClick={() => onSelect(value)} aria-pressed={isSelected} aria-label={`${option.name}: ${value}${!isAvailable ? ' (Sold out)' : ''}`} > {isColorOption ? ( <span className={styles.swatchColor} style={{ backgroundColor: value.toLowerCase() }} /> ) : ( value )} {!isAvailable && <span className={styles.strikethrough} />} </button> ); })} </div> </fieldset> );}.selector { display: flex; flex-direction: column; gap: 1.5rem;}
.optionGroup { border: none; padding: 0; margin: 0;}
.optionName { font-size: 0.875rem; font-weight: 500; margin-bottom: 0.75rem;}
.selectedValue { font-weight: 400; color: var(--color-text-muted);}
.optionValues { display: flex; flex-wrap: wrap; gap: 0.5rem;}
.optionButton { position: relative; padding: 0.75rem 1.25rem; border: 1px solid var(--color-border); background: transparent; cursor: pointer; transition: all 0.15s ease;}
.optionButton:hover { border-color: var(--color-text);}
.optionButton.selected { border-color: var(--color-primary); background: var(--color-primary); color: var(--color-primary-contrast);}
.optionButton.unavailable { opacity: 0.5; cursor: not-allowed;}
.strikethrough { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;}
.strikethrough::after { content: ''; width: 100%; height: 1px; background: currentColor; transform: rotate(-45deg);}
/* Color swatches */.colorSwatch { width: 2rem; height: 2rem; padding: 0; border-radius: 50%; overflow: hidden;}
.swatchColor { display: block; width: 100%; height: 100%;}Updating the URL
Keep the selected variant in the URL for sharing and browser history:
import { useEffect } from 'react';
export function useProductState(product: Product): ProductState { // ... existing code
// Update URL when variant changes useEffect(() => { if (!selectedVariant) return;
const url = new URL(window.location.href); url.searchParams.set('variant', String(selectedVariant.id));
window.history.replaceState({}, '', url.toString()); }, [selectedVariant]);
// Initialize from URL if variant param exists const [selectedOptions, setSelectedOptions] = useState<Record<string, string>>(() => { // Check URL for variant ID const url = new URL(window.location.href); const variantId = url.searchParams.get('variant');
if (variantId) { const urlVariant = product.variants.find((variant) => variant.id === Number(variantId));
if (urlVariant) { return buildOptionsFromVariant(product.options, urlVariant); } }
// Fallback to first available variant const initialVariant = product.variants.find((variant) => variant.available) || product.variants[0];
return buildOptionsFromVariant(product.options, initialVariant); });
// ... rest of hook}Syncing Media with Variant
Update the gallery when variant changes:
import { useProductState } from '@/hooks/useProductState';import { MediaGallery } from './MediaGallery';import { ProductForm } from './ProductForm';import type { Product } from '@/types';
interface ProductPageProps { product: Product;}
export function ProductPage({ product }: ProductPageProps) { const productState = useProductState(product);
// Find the image associated with the selected variant const selectedImageId = productState.selectedVariant?.image; const initialImageIndex = selectedImageId ? product.images.findIndex((img) => img.id === selectedImageId) : 0;
return ( <div className="product-page"> <MediaGallery images={product.images} initialIndex={Math.max(0, initialImageIndex)} key={selectedImageId} // Reset gallery when variant changes />
<ProductForm product={product} productState={productState} /> </div> );}Handling Inventory Levels
Show stock indicators based on inventory:
import type { ProductVariant } from '@/types';
interface StockIndicatorProps { variant: ProductVariant | null; lowStockThreshold?: number;}
export function StockIndicator({ variant, lowStockThreshold = 5 }: StockIndicatorProps) { if (!variant) { return null; }
if (!variant.available) { return <div className="stock-indicator stock-indicator--out">Out of Stock</div>; }
// Check inventory if tracked const inventory = variant.inventoryQuantity;
if (inventory !== null && inventory <= lowStockThreshold) { return ( <div className="stock-indicator stock-indicator--low">Only {inventory} left in stock</div> ); }
return <div className="stock-indicator stock-indicator--in">In Stock</div>;}Using Zustand for Complex Products
For products with many interactions, consider a Zustand store:
import { create } from 'zustand';import type { Product, ProductVariant } from '@/types';
interface ProductStore { product: Product | null; selectedOptions: Record<string, string>; quantity: number;
// Derived (as getters) getSelectedVariant: () => ProductVariant | null; getPrice: () => number; getIsAvailable: () => boolean;
// Actions initialize: (product: Product) => void; selectOption: (optionName: string, value: string) => void; setQuantity: (quantity: number) => void; reset: () => void;}
export const useProductStore = create<ProductStore>((set, get) => ({ product: null, selectedOptions: {}, quantity: 1,
getSelectedVariant: () => { const { product, selectedOptions } = get(); if (!product) return null;
return ( product.variants.find((variant) => Object.entries(selectedOptions).every( ([name, value], index) => variant.options[index] === value ) ) ?? null ); },
getPrice: () => { const variant = get().getSelectedVariant(); return variant?.price ?? get().product?.price ?? 0; },
getIsAvailable: () => { const variant = get().getSelectedVariant(); return variant?.available ?? false; },
initialize: (product) => { const firstAvailable = product.variants.find((v) => v.available) || product.variants[0]; const options: Record<string, string> = {};
product.options.forEach((option, index) => { options[option.name] = firstAvailable.options[index]; });
set({ product, selectedOptions: options, quantity: 1 }); },
selectOption: (optionName, value) => { set((state) => ({ selectedOptions: { ...state.selectedOptions, [optionName]: value }, })); },
setQuantity: (quantity) => { if (quantity >= 1) { set({ quantity }); } },
reset: () => { set({ product: null, selectedOptions: {}, quantity: 1 }); },}));Key Takeaways
- Custom hook for product state: Encapsulate selection, variant matching, and availability logic
- Memoize computed values: Use
useMemofor variant matching to avoid recalculating on every render - Track availability per option: Help users avoid selecting out-of-stock combinations
- Sync with URL: Keep variant ID in URL for sharing and back button support
- Update media with variant: Switch gallery to show variant-specific images
- Show inventory levels: Display low stock warnings when inventory is tracked
- Consider Zustand for complexity: If product state is needed across many components
In the next lesson, we’ll handle global UI state for drawers, modals, and notifications.
Finished this lesson?
Mark it complete to track your progress.
Discussion
Loading comments...