Context provider and hook for managing product state with variation selection support for product detail pages.
A context provider and hook for managing product state on product detail pages. Provides variation selection for variable products and a convenient accessor for product fields.
The recommended way to use useProduct is to create application-specific ProductProvider and useProduct exports using createProductContext and useProductContext. This gives you full type safety with your codegen-generated product and variation types:
import React, { PropsWithChildren, useContext } from 'react'; import { createProductContext, useProductContext, Product, ProductVariation } from '@woographql/react-hooks'; // Your codegen-generated types (or manually defined) interface MyProduct extends Product { sku: string; onSale: boolean; regularPrice: string; salePrice: string; } interface MyVariation extends ProductVariation { sku: string; stockStatus: string; } // Create typed context and hook const productContext = createProductContext<MyProduct, MyVariation>(); export const useProduct = () => useContext(productContext); // Create typed provider export function ProductProvider({ product, children }: PropsWithChildren<{ product: MyProduct }>) { const store = useProductContext<MyProduct, MyVariation>(product); const { Provider } = productContext; return <Provider value={store}>{children}</Provider>; }
Your components then get full type safety — get('sku') returns string, data is typed as MyProduct | null, and selectedVariation is typed as MyVariation | null.
useProduct and ProductProvider work together to:
get() helper that returns variation-specific or product-level datacreateProductContext and useProductContextimport { ProductProvider } from '@woographql/react-hooks'; function ProductPage({ product }) { return ( <ProductProvider product={product}> <ProductDetails /> <VariationSelector /> <AddToCartSection /> </ProductProvider> ); }
import { useProduct } from '@woographql/react-hooks'; function ProductDetails() { const { data, get } = useProduct(); return ( <div> <h1>{data?.name}</h1> <p className="price">{get('price')}</p> <div dangerouslySetInnerHTML={{ __html: get('description') as string }} /> </div> ); }
The default useProduct hook uses the base Product and ProductVariation types. For custom types, see Recommended Usage above.
function useProduct(): ProductContext<Product, ProductVariation>; interface ProductContext<ProductType extends Product, VariationType extends ProductVariation> { data: ProductType | null; isVariableProduct: boolean; hasSelectedVariation: boolean; selectedVariation: VariationType | null; get: <K extends keyof ProductType | keyof VariationType>(field: K) => | (K extends keyof VariationType ? VariationType[K] : never) | (K extends keyof ProductType ? ProductType[K] : never) | null; selectVariation: (variation?: VariationType) => void; updateProduct: (product: ProductType) => void; }
| Property | Type | Description |
|---|---|---|
data | ProductType | null | The current product data |
isVariableProduct | boolean | True if product type is VARIABLE |
hasSelectedVariation | boolean | True when a variation is selected |
selectedVariation | VariationType | null | The currently selected variation |
get | function | Get a field from variation (if selected) or product, with inferred return type |
selectVariation | function | Select or clear a variation |
updateProduct | function | Update the product data |
| Function | Description |
|---|---|
createProductContext<P, V>() | Create a typed React context for custom product/variation types |
useProductContext<P, V>(product) | Create a product store with custom types (used inside your provider) |
The get() function intelligently returns field values based on whether a variation is selected:
const { get, hasSelectedVariation } = useProduct(); // For simple products or when no variation is selected: // Returns the product's price get('price'); // "$29.99" // For variable products with a variation selected: // Returns the variation's price (falls back to product if variation doesn't have it) get('price'); // "$34.99" (variation price) get('name'); // "Blue T-Shirt - Large" (product name, variations don't have names)
This makes it easy to build components that work for both simple and variable products:
function PriceDisplay() { const { get, hasSelectedVariation, isVariableProduct } = useProduct(); if (isVariableProduct && !hasSelectedVariation) { return <span className="price-range">{get('price')}</span>; } return ( <div className="price"> {get('salePrice') && ( <span className="sale-price">{get('salePrice')}</span> )} <span className={get('salePrice') ? 'regular-price strikethrough' : ''}> {get('regularPrice') || get('price')} </span> </div> ); }
import { useProduct } from '@woographql/react-hooks'; function VariationSelector() { const { data, isVariableProduct, hasSelectedVariation, selectedVariation, selectVariation, } = useProduct(); if (!isVariableProduct || !data) return null; const variations = (data as VariableProduct).variations?.nodes || []; const attributes = data.attributes?.nodes || []; return ( <div className="variation-selector"> {attributes.map((attribute) => ( <div key={attribute.name} className="attribute-group"> <label>{attribute.label || attribute.name}</label> <select onChange={(e) => { const variation = variations.find((v) => v.attributes?.nodes?.some( (a) => a.name === attribute.name && a.value === e.target.value ) ); selectVariation(variation); }} > <option value="">Select {attribute.label || attribute.name}</option> {attribute.options?.map((option) => ( <option key={option} value={option}> {option} </option> ))} </select> </div> ))} {hasSelectedVariation && ( <button onClick={() => selectVariation()}>Clear Selection</button> )} </div> ); }
For products with multiple attributes (e.g., Size AND Color):
import { useState } from 'react'; import { useProduct } from '@woographql/react-hooks'; function MultiAttributeSelector() { const { data, isVariableProduct, selectVariation } = useProduct(); const [selections, setSelections] = useState<Record<string, string>>({}); if (!isVariableProduct || !data) return null; const variations = (data as VariableProduct).variations?.nodes || []; const attributes = data.attributes?.nodes || []; const handleAttributeChange = (attributeName: string, value: string) => { const newSelections = { ...selections, [attributeName]: value }; setSelections(newSelections); // Find variation that matches all selections const matchingVariation = variations.find((variation) => { const varAttrs = variation.attributes?.nodes || []; return Object.entries(newSelections).every(([name, val]) => { if (!val) return true; // Skip empty selections return varAttrs.some((a) => a.name === name && a.value === val); }); }); if (matchingVariation) { selectVariation(matchingVariation); } }; return ( <div className="attribute-selectors"> {attributes.map((attr) => ( <div key={attr.name}> <label>{attr.label}</label> <div className="options"> {attr.options?.map((option) => ( <button key={option} className={selections[attr.name!] === option ? 'selected' : ''} onClick={() => handleAttributeChange(attr.name!, option)} > {option} </button> ))} </div> </div> ))} </div> ); }
import { useProduct, useCartMutations } from '@woographql/react-hooks'; function AddToCartButton() { const { data, isVariableProduct, hasSelectedVariation, selectedVariation, get } = useProduct(); const { mutate, quantityFound, fetching } = useCartMutations({ productId: data?.databaseId || 0, variationId: selectedVariation?.databaseId, variation: selectedVariation?.attributes?.nodes?.map((attr) => ({ attributeName: attr.name!, attributeValue: attr.value!, })), }); const canAddToCart = !isVariableProduct || hasSelectedVariation; const inStock = get('stockStatus') === 'IN_STOCK'; const handleClick = () => { if (quantityFound) { mutate('removeItemsFromCart', {}); } else { mutate('addToCart', { quantity: 1 }); } }; return ( <button onClick={handleClick} disabled={!canAddToCart || !inStock || fetching} > {fetching ? 'Processing...' : !inStock ? 'Out of Stock' : !canAddToCart ? 'Select Options' : quantityFound ? 'Remove from Cart' : 'Add to Cart'} </button> ); }
import { useProduct } from '@woographql/react-hooks'; function ProductGallery() { const { data, selectedVariation, get } = useProduct(); // Use variation image if available, otherwise product gallery const mainImage = selectedVariation?.image || data?.image; const galleryImages = data?.galleryImages?.nodes || []; return ( <div className="product-gallery"> <div className="main-image"> {mainImage && ( <img src={mainImage.sourceUrl} alt={mainImage.altText || data?.name} /> )} </div> <div className="thumbnails"> {galleryImages.map((image, index) => ( <img key={index} src={image.sourceUrl} alt={image.altText || `${data?.name} ${index + 1}`} /> ))} </div> </div> ); }
The updateProduct function allows you to update product data without remounting:
import { useProduct } from '@woographql/react-hooks'; function ProductRefresher() { const { data, updateProduct } = useProduct(); const refreshProduct = async () => { const response = await fetch(`/api/product/${data?.databaseId}`); const freshProduct = await response.json(); updateProduct(freshProduct); }; return ( <button onClick={refreshProduct}> Refresh Product Data </button> ); }
import { ProductProvider, useProduct, useCartMutations, } from '@woographql/react-hooks'; function ProductPage({ product }) { return ( <ProductProvider product={product}> <div className="product-page"> <div className="product-layout"> <ProductGallery /> <div className="product-info"> <ProductHeader /> <ProductPrice /> <ProductVariations /> <ProductAddToCart /> <ProductMeta /> </div> </div> <ProductDescription /> </div> </ProductProvider> ); } function ProductHeader() { const { data, get } = useProduct(); return ( <header> <h1>{data?.name}</h1> <p className="sku">SKU: {get('sku') || 'N/A'}</p> </header> ); } function ProductPrice() { const { get, isVariableProduct, hasSelectedVariation } = useProduct(); const price = get('price') as string; const regularPrice = get('regularPrice') as string; const salePrice = get('salePrice') as string; const onSale = get('onSale') as boolean; if (isVariableProduct && !hasSelectedVariation) { return <p className="price-range">{price}</p>; } return ( <div className="price"> {onSale ? ( <> <span className="sale">{salePrice}</span> <span className="regular strikethrough">{regularPrice}</span> </> ) : ( <span>{price}</span> )} </div> ); } function ProductVariations() { const { data, isVariableProduct, selectVariation } = useProduct(); if (!isVariableProduct) return null; const variations = (data as any).variations?.nodes || []; return ( <div className="variations"> {data?.attributes?.nodes?.map((attr) => ( <div key={attr.name} className="attribute"> <label>{attr.label}</label> <select onChange={(e) => { const v = variations.find((v: any) => v.attributes?.nodes?.some( (a: any) => a.name === attr.name && a.value === e.target.value ) ); selectVariation(v); }} > <option value="">Choose {attr.label}</option> {attr.options?.map((opt) => ( <option key={opt} value={opt}>{opt}</option> ))} </select> </div> ))} </div> ); } function ProductAddToCart() { const { data, isVariableProduct, hasSelectedVariation, selectedVariation, get } = useProduct(); const { mutate, quantityFound, fetching } = useCartMutations({ productId: data?.databaseId || 0, variationId: selectedVariation?.databaseId, variation: selectedVariation?.attributes?.nodes?.map((a) => ({ attributeName: a.name!, attributeValue: a.value!, })), }); const canAdd = !isVariableProduct || hasSelectedVariation; const inStock = get('stockStatus') === 'IN_STOCK'; return ( <div className="add-to-cart"> <button onClick={() => quantityFound ? mutate('removeItemsFromCart', {}) : mutate('addToCart', { quantity: 1 }) } disabled={!canAdd || !inStock || fetching} > {!canAdd ? 'Select Options' : !inStock ? 'Out of Stock' : fetching ? 'Processing...' : quantityFound ? `In Cart (${quantityFound})` : 'Add to Cart'} </button> </div> ); } function ProductMeta() { const { data } = useProduct(); return ( <div className="meta"> {data?.productCategories?.nodes?.length > 0 && ( <p> Categories:{' '} {data.productCategories.nodes.map((c) => c.name).join(', ')} </p> )} {data?.productTags?.nodes?.length > 0 && ( <p> Tags: {data.productTags.nodes.map((t) => t.name).join(', ')} </p> )} </div> ); } function ProductDescription() { const { data } = useProduct(); return ( <div className="description"> <h2>Description</h2> <div dangerouslySetInnerHTML={{ __html: data?.description || '' }} /> </div> ); }
query GetProduct($id: ID!) { product(id: $id, idType: DATABASE_ID) { databaseId name slug type description shortDescription image { sourceUrl altText } galleryImages { nodes { sourceUrl altText } } ... on ProductWithPricing { price regularPrice salePrice onSale } ... on InventoriedProduct { sku stockStatus stockQuantity soldIndividually } ... on ProductWithAttributes { attributes { nodes { name label options variation } } } productCategories { nodes { name slug } } productTags { nodes { name slug } } ... on VariableProduct { variations(first: 100) { nodes { databaseId name price regularPrice salePrice stockStatus image { sourceUrl altText } attributes { nodes { name value } } } } } } }
interface ProductContext<ProductType extends Product, VariationType extends ProductVariation> { data: ProductType | null; isVariableProduct: boolean; hasSelectedVariation: boolean; selectedVariation: VariationType | null; get: <K extends keyof ProductType | keyof VariationType>(field: K) => | (K extends keyof VariationType ? VariationType[K] : never) | (K extends keyof ProductType ? ProductType[K] : never) | null; selectVariation: (variation?: VariationType) => void; updateProduct: (product: ProductType) => void; }
The base Product and ProductVariation interfaces include an [key: string]: unknown index signature, so codegen-generated types that extend them can include additional fields without typecasting.