State Management and Shopify APIs Intermediate 12 min read

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:

  1. Selected options: Which Size? Which Color?
  2. Current variant: The variant matching selected options
  3. Availability: Is the selected variant in stock?
  4. Price: Current and compare-at price
  5. Quantity: How many to add
  6. Media: Which image to show based on selection

The Product State Hook

Create a custom hook that encapsulates all product logic:

src/hooks/useProductState.ts
/*
* 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 } object
function 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

src/components/product/ProductForm.tsx
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

src/components/product/VariantSelector.tsx
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>
);
}
src/components/product/VariantSelector.module.css
.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:

src/hooks/useProductState.ts
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:

src/components/product/ProductPage.tsx
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:

src/components/product/StockIndicator.tsx
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:

src/stores/product.ts
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

  1. Custom hook for product state: Encapsulate selection, variant matching, and availability logic
  2. Memoize computed values: Use useMemo for variant matching to avoid recalculating on every render
  3. Track availability per option: Help users avoid selecting out-of-stock combinations
  4. Sync with URL: Keep variant ID in URL for sharing and back button support
  5. Update media with variant: Switch gallery to show variant-specific images
  6. Show inventory levels: Display low stock warnings when inventory is tracked
  7. 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...