Skip to main content

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.Root supplies typed search results, so you can branch on loading, error, or empty states at the layout level.
  • ProductBadge.Root returns null when the badge plugin is not enabled, letting the same component tree ship to channels without conditional guards.
  • ProductList.AddToCart delegates to the underlying AddItemToOrder implementation while exposing a simplified slot API—swap it for AddItemToOrder.Root when 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>
);
}
  • cartChannel events 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.Quantity sub-components merge passed props, so adding onClick for analytics or instrumentation does not remove the default quantity adjustments.
  • Derive totals from the order object 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.Root inside checkout to show live order totals.
  • Inject validation by returning promises from onComplete and rejecting when step data is invalid.

4. Share Logic Across Surfaces

  • Extract orchestration code (event emitters, query hooks, plugin request helpers) into /commerce or /lib modules and re-use them in Next.js pages, Expo apps, or Storybook stories.
  • Use the same pluginConfigs array 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 useQueryClient with 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.