Composing Store Components
Haus Storefront components are intentionally headless so you can orchestrate bespoke UX flows. This guide demonstrates how to combine packages from @haus-storefront-react with the core SDK hooks to deliver production-grade experiences.
Reference docs
1. Discovery Grid with Progressive Enhancement
Combine ProductList, ProductBadge, and AddItemToOrder to layer merchandising data without sacrificing performance.
import { ProductList } from "@haus-storefront-react/product-list";
import { ProductBadge } from "@haus-storefront-react/vendure-plugin-configs/badge";
export function DiscoveryGrid() {
return (
<ProductList.Root
searchInputProps={{
term: "",
groupByProduct: true,
take: 12,
}}
productListIdentifier='main-product-list'
infinitePagination
>
{({ products, isLoading, error }) => {
if (isLoading) return <p>Loading…</p>;
if (error) return <p role='alert'>{error.message}</p>;
if (!products.length) return <p>No products available.</p>;
return (
<ul className='grid gap-6 sm:grid-cols-2 lg:grid-cols-3'>
{products.map((product) => (
<li key={product.productVariantId}>
<ProductList.Item product={product}>
<header className='space-y-2'>
<ProductBadge.Root product={product}>
{({ groupedBadges }) => (
<div className='flex gap-2'>
{Object.values(groupedBadges)
.flat()
.map((badge) => (
<ProductBadge.Item key={badge.id} badge={badge} />
))}
</div>
)}
</ProductBadge.Root>
<ProductList.Image className='aspect-square w-full rounded-md object-cover' />
</header>
<section className='mt-4 space-y-1'>
<h3 className='text-base font-medium'>
{product.productName}
</h3>
<ProductList.Price>
{({ priceWithTax, currencyCode, isFromPrice }) => (
<span className='text-sm text-muted-foreground'>
{isFromPrice ? "From " : ""}
{priceWithTax} {currencyCode}
</span>
)}
</ProductList.Price>
</section>
<footer className='mt-6'>
<ProductList.AddToCart
productVariantId={product.productVariantId}
>
{({
isInCart,
isLoading: addLoading,
error: addError,
}) => (
<div className='space-y-2'>
{!isInCart && (
<ProductList.AddToCart.Button disabled={addLoading}>
{addLoading ? "Adding…" : "Add to cart"}
</ProductList.AddToCart.Button>
)}
{isInCart && (
<ProductList.AddToCart.Quantity.Root
min={1}
max={10}
>
<ProductList.AddToCart.Quantity.Decrement />
<ProductList.AddToCart.Quantity.Input />
<ProductList.AddToCart.Quantity.Increment />
</ProductList.AddToCart.Quantity.Root>
)}
{addError && <p role='alert'>{addError.message}</p>}
</div>
)}
</ProductList.AddToCart>
</footer>
</ProductList.Item>
</li>
))}
</ul>
);
}}
</ProductList.Root>
);
}
- The render prop from
ProductList.Rootsupplies typed search results, so you can branch on loading, error, or empty states at the layout level. ProductBadge.Rootreturnsnullwhen the badge plugin is not enabled, letting the same component tree ship to channels without conditional guards.ProductList.AddToCartdelegates to the underlyingAddItemToOrderimplementation while exposing a simplified slot API—swap it forAddItemToOrder.Rootwhen you need bespoke validation or analytics callbacks.
2. Cross-Surface Cart with Event Bus Sync
Leverage the core EventBus to keep mini-cart, header counter, and checkout views synchronized.
import {
useEventBusOn,
useEventBusEmit,
cartChannel,
} from "@haus-storefront-react/core";
import { Cart } from "@haus-storefront-react/cart";
function useCartSync() {
const emitUpdating = useEventBusEmit(cartChannel, "cart:updating");
const [payload] = useEventBusOn(cartChannel, "cart:updated");
return {
cartSnapshot: payload?.order,
emitUpdating,
};
}
export function CartDrawer() {
const { emitUpdating } = useCartSync();
return (
<Cart.Root>
{({ data: order, isLoading, error, totalItems, currencyCode }) => (
<aside
role='dialog'
aria-busy={isLoading}
className='w-full max-w-md space-y-6'
>
<header className='flex items-center justify-between'>
<div>
<p className='text-sm font-medium'>Your cart</p>
{error && (
<p role='alert' className='text-xs text-red-600'>
{error.message}
</p>
)}
</div>
<span className='text-sm text-muted-foreground'>
{totalItems} items
</span>
</header>
<Cart.Empty>
<p>Your cart is empty.</p>
</Cart.Empty>
{order && totalItems > 0 && (
<>
<Cart.Items>
{order.lines.map((line) => (
<Cart.Item key={line.id} orderLine={line}>
<Cart.Item.Image className='h-16 w-16 rounded' />
<div className='flex-1 space-y-1'>
<p className='text-sm font-medium'>
{line.productVariant.product.name}
</p>
<Cart.Item.Price>
{({ priceWithTax }) => (
<span className='text-xs text-muted-foreground'>
{priceWithTax} {currencyCode}
</span>
)}
</Cart.Item.Price>
<Cart.Item.Quantity>
<Cart.Item.Quantity.Decrement
onClick={() => emitUpdating({ reason: "decrement" })}
/>
<Cart.Item.Quantity.Input className='w-12 text-center' />
<Cart.Item.Quantity.Increment
onClick={() => emitUpdating({ reason: "increment" })}
/>
</Cart.Item.Quantity>
</div>
<Cart.Item.Remove>Remove</Cart.Item.Remove>
</Cart.Item>
))}
</Cart.Items>
<div className='space-y-1 text-right'>
<p className='text-sm font-medium'>
Subtotal: {order.totalWithTax} {currencyCode}
</p>
<a
className='inline-flex items-center justify-center rounded bg-primary px-4 py-2 text-sm text-primary-foreground'
href='/checkout'
>
Go to checkout
</a>
</div>
</>
)}
</aside>
)}
</Cart.Root>
);
}
cartChannelevents keep remote surfaces (for example, native apps or embedded widgets) aligned. Emitting on button presses adds observability without rewriting the built-in mutation handlers.Cart.Item.Quantitysub-components merge passed props, so addingonClickfor analytics or instrumentation does not remove the default quantity adjustments.- Derive totals from the
orderobject to display currency-aware summaries alongside call-to-action buttons.
3. Checkout Flow with Step Isolation
Use CheckoutProvider together with component composition to manage multi-step flows without monolithic forms.
import {
CheckoutProvider,
useCheckoutContext,
} from "@haus-storefront-react/checkout";
import { CheckoutAddressStep } from "./CheckoutAddressStep";
import { CheckoutShippingStep } from "./CheckoutShippingStep";
import { CheckoutPaymentStep } from "./CheckoutPaymentStep";
type CheckoutData = {
address: CheckoutAddressStep["value"];
shipping: CheckoutShippingStep["value"];
payment: CheckoutPaymentStep["value"];
};
function CheckoutSteps() {
const { currentStep, nextStep, previousStep } =
useCheckoutContext<CheckoutData>();
return (
<div>
<ol className='flex items-center gap-4'>
{["Address", "Shipping", "Payment"].map((label, index) => (
<li key={label} data-active={currentStep === index}>
{label}
</li>
))}
</ol>
<CheckoutAddressStep
hidden={currentStep !== 0}
onComplete={() => nextStep()}
/>
<CheckoutShippingStep
hidden={currentStep !== 1}
onBack={() => previousStep()}
onComplete={() => nextStep()}
/>
<CheckoutPaymentStep
hidden={currentStep !== 2}
onBack={() => previousStep()}
/>
</div>
);
}
export function CheckoutPage() {
return (
<CheckoutProvider>
<CheckoutSteps />
</CheckoutProvider>
);
}
- Each step can call
useCheckoutContext<CheckoutData>('address')(for example) to read/write only its slice of data. - Combine with
Cart.Rootinside checkout to show live order totals. - Inject validation by returning promises from
onCompleteand rejecting when step data is invalid.
4. Share Logic Across Surfaces
- Extract orchestration code (event emitters, query hooks, plugin request helpers) into
/commerceor/libmodules and re-use them in Next.js pages, Expo apps, or Storybook stories. - Use the same
pluginConfigsarray across channels to guarantee that UI components receive the fields they expect. Pair it with feature flag clients to enable/disable subsets per environment. - Combine
useQueryClientwith component handlers to invalidate or prefetch data when the cart or checkout state changes.
By composing headless primitives in layers—data (SDK) → orchestration (hooks/event bus) → presentation (UI components)—you can scale storefronts that remain maintainable even as business logic grows.