BizzPocket

Interactivity: islands, hooks and sir attributes

On this page

By default a block is server-only: it becomes plain HTML and sends no JavaScript to the browser. When part of a block must react to the shopper, you have three tools, from lightest to heaviest:

Need Use JavaScript sent
A common behaviour: add to cart, carousel, accordion, quantity, wishlist heart, image gallery, search, a product's size / colour choice sir.* attributes none from you (the page already has the small shared runtime)
Your own interaction (a toggle, a form, a calculator) an island only that island
Cart count, signed-in shopper or wishlist state inside an island client hooks only that island
Add to cart from an island and show "Adding..." or the error useCartActions() only that island
One value several islands follow (the chosen size, a quantity) useShared() only those islands
Data after the page loaded (search suggestions, load more) useQuery() only that island
A form island (newsletter, login, signup) useForm() only that island

Start at the top of the table. Reach for an island only when sir cannot do it.


Islands: a 'use client' file

An island is a React component in a file whose first line is 'use client'. Everything else in the block stays server HTML. The island is rendered on the server too (so the page is complete before any JavaScript runs), and later the browser hydrates it, which makes it interactive.

Create blocks/save-for-later/heart.tsx:

blocks/save-for-later/heart.tsx
'use client';
import { useState } from 'react';

export function Heart({ label }: { label: string }) {
  const [on, setOn] = useState(false);
  return (
    <button type="button" aria-pressed={on} onClick={() => setOn(!on)}>
      {on ? '♥' : '♡'} {label}
    </button>
  );
}

Use it from the block file, like any component. Create blocks/save-for-later/save-for-later.tsx:

blocks/save-for-later/save-for-later.tsx
import { block, f } from '@bizzpocket/theme';
import { Heart } from './heart';

export default block({
  name: 'Save for later',
  category: 'content',
  // No `hydrate`: the heart wakes up when it comes near the screen ('visible', the default).
  fields: {
    heading: f.text({ default: 'Like what you see?' }),
    label: f.text({ default: 'Save for later' }),
  },
  render: ({ heading, label }) => (
    <section>
      <h2>{heading}</h2>
      <Heart label={label} />
    </section>
  ),
});

Build it and read the size table. The block's client gz column is now more than 0. That is the size of the island.

The rules

  1. The file's first statement is 'use client'. Usually it lives in the block's folder (blocks/save-for-later/heart.tsx). A 'use client' file elsewhere in the theme (for example components/heart.tsx) is an island too, as soon as a block imports it (directly or through a server file of the theme). It belongs to the first block, in folder order, that imports it; every other block importing it renders the same island. Without the directive a component renders as plain server HTML and its useState and onClick never run in the browser. Using a hook in a file without it stops the build with CLIENT_HOOK_IN_SERVER.
  2. The file name is the island's name. Two client files of one block whose names differ only by extension (heart.ts and heart.tsx), or a folder file and an outside file with the same name, clash. That is DUPLICATE_ISLAND.
  3. Export components by name or as default. export * from './x' is not allowed in a 'use client' file (UNSUPPORTED_CLIENT_EXPORT), and every export name must be a plain identifier.
  4. Props are serialised to JSON. The server writes the island's props into the page and the browser reads them back. So a prop may be a string, number, boolean, null, an array or a plain object. It may not be a function, a class, a new X() instance or a JSX element. The build stops you with NON_SERIALIZABLE_ISLAND_PROP and names the prop. The fix is to move the logic into the island and pass data instead.
  5. No JSX children. Elements you nest inside an island tag are not sent to the browser. Pass what the island needs as props.
  6. An island sees only its own props. It does not receive React context from the rest of the page. If a page needs a shared value, pass it as a prop or read it with a hook.
  7. One island inside another renders as a normal component. Only the outer island hydrates.

What an island may import

Allowed Not allowed
react, react-dom @bizzpocket/theme-sdk (and any sub-path)
@bizzpocket/theme/client (hooks) zod
any npm package (it is bundled into the island)
relative files (./format, ../../components/...)

Importing @bizzpocket/theme-sdk or zod in an island is CLIENT_IMPORT_FORBIDDEN: they are large and belong on the server. Keep schemas and SDK calls in the block file. An island that needs the block's data should receive it as props.

Do not import @bizzpocket/theme (the server entry) in an island either. It is allowed but heavy, so avoid it: it pulls the whole author API into the island's JavaScript. The client entry is the small one.

When does an island wake up? hydrate

hydrate is a setting of the block and applies to all of the block's islands:

tsx
block({ name: 'Header', hydrate: 'load', /* ... */ })
Value The island becomes interactive...
'visible' when it is close to the screen (200 px before it is visible), or after about 5 seconds if the browser cannot tell. This is the default.
'load' as soon as the page's scripts load
'idle' when the browser is idle (at most about 5 seconds after load)

The default, 'visible', keeps the first view fast: islands below the fold cost nothing until the shopper scrolls to them. Set 'load' for interactive things that must work the moment the page appears (a header menu, a cart button): a 'visible' island that is on screen also wakes at once, but only after the page's scripts have run and the browser has checked what is visible. The server HTML is already there in all three cases. Only the interactivity waits.

Seeing the cost

bunx bizzpocket build prints each block's island size in the client gz column (gzip bytes) and the total below. Keep it small. A block with no 'use client' file shows 0.


Client hooks

Inside an island you can read per-shopper state with hooks from @bizzpocket/theme/client. The storefront page is identical for every shopper (so it can be cached), and these hooks fill in the personal part in the browser.

Hook Returns
useCart() { cart, count, ready }: the cart (cart.items, cart.totals...), the item count, and whether the storefront has answered yet
useCustomer() { customer, ready }: the signed-in shopper, or null for a guest
useWishlist() { productIds, has(productId), count, ready }

ready is false until the storefront has replied, and the values are null or empty until then, so render something sensible for that first moment. Create blocks/cart-link/cart-badge.tsx:

blocks/cart-link/cart-badge.tsx
'use client';
import { useCart } from '@bizzpocket/theme/client';

export function CartBadge({ href }: { href: string }) {
  const { count, ready } = useCart();
  return (
    <a href={href}>
      Cart{ready && count > 0 ? ` (${count})` : ''}
    </a>
  );
}

And the block that uses it, blocks/cart-link/cart-link.tsx:

blocks/cart-link/cart-link.tsx
import { block, f } from '@bizzpocket/theme';
import { CartBadge } from './cart-badge';

export default block({
  name: 'Cart link',
  category: 'header',
  // In the header, so interactive at once rather than when visible (the default).
  hydrate: 'load',
  fields: { href: f.link({ default: '/cart' }) },
  render: ({ href }) => <CartBadge href={href ?? '/cart'} />,
});

Where the hooks get their values: the storefront host announces the cart, the shopper and the wishlist as browser events, and the hooks listen for them. In a page that has no storefront host (for example a plain test page), no answer comes, so ready stays false.

cartCount(cart) is also exported from @bizzpocket/theme/client. It counts the items of a cart and never throws.


Cart actions: useCartActions()

sir.addToCart covers a plain button. When an island needs to know how the add went (to show "Adding...", "Added" or the reason it failed), use useCartActions():

Member What it is
add({ productId, variantId?, quantity, handle?, attributes? }) Adds to the cart. Returns a Promise of the updated cart.
update(lineId, quantity) Sets a cart line's quantity. Returns a Promise of the updated cart.
remove(lineId) Removes a cart line. Returns a Promise of the updated cart.
pending true while one of this island's requests waits for the storefront
error { code, message } of the last failure, or null. Cleared when the next action starts.

A failed Promise rejects with { code, message }:

  • code is the store's own reason, for example OUT_OF_STOCK or VARIANT_REQUIRED;
  • TIMEOUT means the storefront did not answer within 15 seconds;
  • message is a sentence you can show the shopper.

The hook sends the same events as sir.addToCart, so it works on every storefront host and needs nothing else. It also opens the cart drawer like the SIR button does. Create blocks/buy-button/buy-button-island.tsx:

blocks/buy-button/buy-button-island.tsx
'use client';
import { useState } from 'react';
import { useCartActions } from '@bizzpocket/theme/client';

export function BuyButton({ productId, handle }: { productId: string; handle: string }) {
  const { add, pending, error } = useCartActions();
  const [added, setAdded] = useState(false);
  const onClick = () => {
    setAdded(false);
    add({ productId, handle, quantity: 1 }).then(
      () => setAdded(true),
      () => {}, // `error` already holds the reason
    );
  };
  return (
    <div>
      <button type="button" disabled={pending} onClick={onClick}>
        {pending ? 'Adding...' : added ? 'Added' : 'Add to cart'}
      </button>
      {error !== null && <p role="alert">{error.message}</p>}
    </div>
  );
}

And the block, blocks/buy-button/buy-button.tsx:

blocks/buy-button/buy-button.tsx
import { block, product } from '@bizzpocket/theme';
import { BuyButton } from './buy-button-island';

export default block({
  name: 'Buy button',
  category: 'commerce',
  data: (_props, page) => ({ item: product({ handle: page.route.handle }) }),
  render: ({ item }) => (item ? <BuyButton productId={item.id} handle={item.handle} /> : null),
});

Good to know:

  • An add settles on the storefront's next cart update. If two adds run at once, the first update settles both.
  • update and remove fail only with TIMEOUT today: the storefront does not yet report line errors.
  • Outside the browser (in the server render) the actions reject with NO_WINDOW. Call them from event handlers only.
  • Without React, addToCart(input), updateCartLine(lineId, quantity) and removeCartLine(lineId) return the same Promises.

Shared state between islands: useShared()

An island sees only its own props (rule 6 above). When several islands must follow one value (a size picker and the price beside it, say), share it with useShared(key, initial). It works like useState, but every island on the page that uses the same key sees the same value:

tsx
const [size, setSize] = useShared<string | null>('size:' + productId, null);
  • The server render and the island's first render in the browser show initial. Right after the island wakes up, it takes the value already set by another island (or by a SIR behaviour), then follows every change.
  • set(next) or set((prev) => next) changes the value for every island at once. Setting the same value again does nothing.
  • Keys are per page. Nothing is stored after the shopper leaves the page. Prefix keys with the thing they belong to (size:<productId>), so two products on one page do not clash.
  • The value should be plain data (a string, a number, an object). A function given to set is treated as an updater, as in useState.

How it works, for code that is not an island (a SIR behaviour, a script):

  • The values live in globalThis.__bpShared, a Map<string, unknown> that is created on first use. Islands are bundled separately, so a store inside a module would give each island its own copy.

  • Every change is announced on window as the event bizzpocket:shared:<key>, with the new value as its detail.

  • So to write a value yourself, set it in the map and send the event:

    ts
    const store = ((globalThis as any).__bpShared ??= new Map());
    store.set(key, value);
    window.dispatchEvent(new CustomEvent(`bizzpocket:shared:${key}`, { detail: value }));
  • SIR's variant picker writes the key variant:<productId>, so an island can follow the shopper's variant with useShared('variant:' + productId, null).

  • Outside React: getShared(key), setShared(key, value) and subscribeShared(key, listener), which returns the function that stops listening.

The worked example below puts it to use.


Data from the browser: useQuery()

Most data belongs in data(): it is fetched on the server, once per page, and cached. Some data is needed only after the shopper does something: search suggestions while they type, the next page of a grid. For that, an island calls useQuery(op, vars, { enabled }):

Member What it is
data the operation's result; undefined until it arrives (and null when the store has none)
loading true while a request is running. The server render always shows true.
error { code } when the request failed, else null
refetch() asks again, skipping the cache

Create blocks/search-box/suggest.tsx:

blocks/search-box/suggest.tsx
'use client';
import { useState } from 'react';
import { useQuery } from '@bizzpocket/theme/client';

type Hit = { id: string; handle: string; title: string };

export function Suggest({ placeholder }: { placeholder: string }) {
  const [q, setQ] = useState('');
  const term = q.trim();
  const { data, loading } = useQuery<{ hits: Hit[] }>(
    'productSearch',
    { q: term, limit: 5 },
    { enabled: term.length >= 3 },
  );
  return (
    <div>
      <input type="search" value={q} placeholder={placeholder} onChange={(e) => setQ(e.target.value)} />
      {term.length >= 3 && loading && <p>Searching...</p>}
      <ul>
        {(data?.hits ?? []).map((h) => (
          <li key={h.id}>
            <a href={`/products/${encodeURIComponent(h.handle)}`}>{h.title}</a>
          </li>
        ))}
      </ul>
    </div>
  );
}

And the block, blocks/search-box/search-box.tsx:

blocks/search-box/search-box.tsx
import { block, f } from '@bizzpocket/theme';
import { Suggest } from './suggest';

export default block({
  name: 'Search box',
  category: 'header',
  hydrate: 'load',
  fields: { placeholder: f.text({ default: 'Search toys' }) },
  render: ({ placeholder }) => <Suggest placeholder={placeholder} />,
});

A new keystroke cancels the request still running, and so does the island leaving the page. Each answer is kept for the rest of the visit, in memory, and shared by every island, so typing back to an earlier word costs nothing. data.md lists the operations a browser may run and the route behind the hook.


Forms: useForm()

useForm(submit, { validate? }) runs a form island's submit for you:

  1. It stops the page from reloading.
  2. It runs the browser's own checks (required, type="email", pattern, minLength...) and shows the browser's message when one fails.
  3. It calls your optional validate(data, form). Return a message to stop and show it; return nothing to go on.
  4. It calls submit(data, form) with the form's FormData. Throw an Error (or { message }) to show that message.
Member What it is
onSubmit put it on the form: <form onSubmit={onSubmit}>
pending submit is running. A second submit is ignored meanwhile.
error the message of the last failure, or null
done the last submit succeeded
result what the last successful submit returned
reset() back to the start

There is no validation library. The browser's checks and validate are enough for a storefront form. Create blocks/newsletter-signup/signup-form.tsx:

blocks/newsletter-signup/signup-form.tsx
'use client';
import { useForm } from '@bizzpocket/theme/client';

export function SignupForm({ action, button }: { action: string; button: string }) {
  const { onSubmit, pending, error, done } = useForm(
    async (data) => {
      const res = await fetch(action, { method: 'POST', body: data });
      if (!res.ok) throw new Error('We could not sign you up. Please try again.');
    },
    {
      validate: (data) => (String(data.get('email')).endsWith('@example.com') ? 'Please use your own email.' : undefined),
    },
  );
  if (done) return <p>Thanks! Check your inbox.</p>;
  return (
    <form action={action} method="post" onSubmit={onSubmit}>
      <input name="email" type="email" required placeholder="you@email.com" />
      <button type="submit" disabled={pending}>{pending ? 'Sending...' : button}</button>
      {error !== null && <p role="alert">{error}</p>}
    </form>
  );
}

And the block, blocks/newsletter-signup/newsletter-signup.tsx:

blocks/newsletter-signup/newsletter-signup.tsx
import { block, f } from '@bizzpocket/theme';
import { SignupForm } from './signup-form';

export default block({
  name: 'Newsletter signup',
  category: 'content',
  fields: {
    action: f.link({ default: '/api/newsletter' }),
    button: f.text({ default: 'Sign me up' }),
  },
  render: ({ action, button }) => <SignupForm action={action ?? '/api/newsletter'} button={button} />,
});

The form also has action and method, so it still posts the old way if a shopper submits it before the island has woken up. Without React, runFormSubmit(form, submitter, submit, validate) runs the same steps.


Worked example: size picker, add to cart and a price that follows

A product page with three islands that never pass props to each other:

  • a size picker;
  • the price, which follows the chosen size;
  • the add-to-cart button, which adds the chosen size.

They share the chosen variant id through useShared, under the key size:<productId>.

The size picker, blocks/size-buy/size-picker.tsx:

blocks/size-buy/size-picker.tsx
'use client';
import { useShared } from '@bizzpocket/theme/client';

export type Size = { id: string; title: string; inStock: boolean; amount: number; currency: string };

export function SizePicker({ productId, sizes }: { productId: string; sizes: Size[] }) {
  const [picked, setPicked] = useShared<string | null>(`size:${productId}`, null);
  return (
    <div role="radiogroup" aria-label="Size">
      {sizes.map((s) => (
        <button
          key={s.id}
          type="button"
          role="radio"
          aria-checked={picked === s.id}
          disabled={!s.inStock}
          onClick={() => setPicked(s.id)}
        >
          {s.title}
        </button>
      ))}
    </div>
  );
}

The price, blocks/size-buy/size-price.tsx. Before a size is picked it shows the product's own price:

blocks/size-buy/size-price.tsx
'use client';
import { useShared } from '@bizzpocket/theme/client';
import type { Size } from './size-picker';

export function SizePrice({ productId, sizes, amount, currency }: { productId: string; sizes: Size[]; amount: number; currency: string }) {
  const [picked] = useShared<string | null>(`size:${productId}`, null);
  const size = sizes.find((s) => s.id === picked);
  const text = new Intl.NumberFormat(undefined, { style: 'currency', currency: size?.currency ?? currency }).format(size?.amount ?? amount);
  return <p aria-live="polite">{text}</p>;
}

The button, blocks/size-buy/size-add.tsx:

blocks/size-buy/size-add.tsx
'use client';
import { useCartActions, useShared } from '@bizzpocket/theme/client';

export function SizeAdd({ productId, handle }: { productId: string; handle: string }) {
  const [picked] = useShared<string | null>(`size:${productId}`, null);
  const { add, pending, error } = useCartActions();
  return (
    <div>
      <button
        type="button"
        disabled={picked === null || pending}
        onClick={() => {
          if (picked !== null) add({ productId, variantId: picked, handle, quantity: 1 }).catch(() => {});
        }}
      >
        {picked === null ? 'Choose a size' : pending ? 'Adding...' : 'Add to cart'}
      </button>
      {error !== null && <p role="alert">{error.message}</p>}
    </div>
  );
}

The block, blocks/size-buy/size-buy.tsx. It turns the product's variants into plain data for the islands:

blocks/size-buy/size-buy.tsx
import { block, product } from '@bizzpocket/theme';
import { SizeAdd } from './size-add';
import { SizePicker } from './size-picker';
import { SizePrice } from './size-price';

export default block({
  name: 'Size and buy',
  category: 'commerce',
  hydrate: 'load',
  data: (_props, page) => ({ item: product({ handle: page.route.handle }) }),
  render: ({ item }) => {
    if (!item) return null;
    const sizes = item.variants.map((v) => ({
      id: v.id,
      title: v.title,
      inStock: v.inStock,
      amount: v.price.amount,
      currency: v.price.currency,
    }));
    return (
      <section>
        <h1>{item.title}</h1>
        <SizePrice productId={item.id} sizes={sizes} amount={item.price.amount} currency={item.price.currency} />
        <SizePicker productId={item.id} sizes={sizes} />
        <SizeAdd productId={item.id} handle={item.handle} />
      </section>
    );
  },
});

The three islands are bundled separately, and each still sees the size the shopper picks. hydrate: 'load' wakes them together. With the default 'visible', an island that wakes later still starts from the size already picked.

On a product page you build from scratch, SIR's variant picker (no React at all) is usually the lighter choice. It writes variant:<productId>, which an island can follow the same way.


sir attributes: behaviours without React

For common behaviours you do not write any client code. You add attributes to your server-rendered HTML, and a small runtime that every storefront page already loads wires them up. This is called SIR (Storefront Interaction Runtime). It is the lightest way to be interactive.

Import sir from @bizzpocket/theme and spread the result onto the element:

tsx
<button {...sir.addToCart({ 'data-product-id': p.id })}>Add to cart</button>

Every helper adds data-bp-island="<name>" for you and keeps the string attributes you pass. Attribute names are typed, so your editor tells you what exists. (Values must be strings. A value that is not a string is left out.)

Helper What it does Attributes you pass Markup it needs inside
sir.addToCart(attrs) Adds a product to the cart, then opens the cart drawer. Works on a <button>, or on an <a> or <div>. 'data-product-id' (required), 'data-variant-id', 'data-qty', 'data-handle' none
sir.cartDrawer() Opens the cart drawer when clicked. none none
sir.wishlistToggle(attrs) A heart. Toggles a product in the wishlist and sets aria-pressed. A signed-out shopper is sent to sign in. 'data-product-id' (required), 'data-wished' ('true' or 'false') none
sir.qtyStepper(attrs) A minus / value / plus control. 'data-min', 'data-max', 'data-step' [data-bp-qty-decrement], [data-bp-qty-increment] buttons and a [data-bp-qty-input] (an <input>)
sir.carousel(attrs) A horizontal, snapping row with previous and next buttons and arrow-key support. 'data-step-px' [data-bp-carousel-track] (the scrolling element), [data-bp-carousel-prev], [data-bp-carousel-next]
sir.accordion(attrs) Expand and collapse sections. 'data-single' ('true' makes it one-open-at-a-time) [data-bp-accordion-trigger] buttons; each controls the element named by its aria-controls, else the next sibling
sir.imageGallery() Thumbnails that swap the main picture. none an <img data-bp-gallery-main>; each thumbnail is {...sir.imageGalleryThumb({ 'data-src': url, 'data-alt': text })}
sir.search(attrs) Sends the shopper to /search?q=.... 'data-action' (a path on the same site; default /search) [data-bp-search-input] for the text. If the root is a <form> its submit is used
sir.variantPicker(attrs) A product's option buttons (Size, Colour...). A pick updates the price, compare-at price, SKU, stock text, the add-to-cart button and the main picture, and writes ?variant= to the URL. 'data-product-id' (required), 'data-locale', 'data-update-url' ('false' keeps the URL) option buttons {...sir.variantOption(...)}, the variant table <script {...sir.variantData(rows)} />; optional targets. See Variant picker

An accordion, as a complete block. Create blocks/faq/faq.tsx:

blocks/faq/faq.tsx
import { block, f, sir } from '@bizzpocket/theme';

export default block({
  name: 'FAQ',
  category: 'content',
  fields: {
    heading: f.text({ default: 'Questions' }),
    questions: f.list(
      {
        question: f.text({ default: 'How long does delivery take?' }),
        answer: f.text({ multiline: true, default: 'Usually 3 to 5 working days.' }),
      },
      { max: 12, label: 'Questions' },
    ),
  },
  render: ({ heading, questions }) => (
    <section>
      <h2>{heading}</h2>
      <div {...sir.accordion({ 'data-single': 'true' })}>
        {questions.map((q, i) => (
          <div key={i}>
            <button type="button" data-bp-accordion-trigger aria-controls={`faq-${i}`}>
              {q.question}
            </button>
            <div id={`faq-${i}`}>{q.answer}</div>
          </div>
        ))}
      </div>
    </section>
  ),
});

An add-to-cart button on a product card. Create blocks/quick-buy/quick-buy.tsx:

blocks/quick-buy/quick-buy.tsx
import { block, f, products, sir } from '@bizzpocket/theme';

export default block({
  name: 'Quick buy',
  category: 'products',
  fields: {
    collection: f.collection(),
    count: f.number({ default: 3, min: 1, max: 8 }),
  },
  data: ({ collection, count }) => ({ items: products({ collection, first: count }) }),
  render: ({ items }) => (
    <ul>
      {(items?.products ?? []).map((p) => (
        <li key={p.id}>
          {p.title}
          <button type="button" {...sir.addToCart({ 'data-product-id': p.id, 'data-handle': p.handle })}>
            Add to cart
          </button>
          <button type="button" aria-label="Save" {...sir.wishlistToggle({ 'data-product-id': p.id })}>
            ♡
          </button>
        </li>
      ))}
    </ul>
  ),
});

The block above has client gz 0. sir costs the block nothing, because the runtime is shared.

Variant picker

A product with options (Size, Colour) needs the shopper's choice to change the price, the SKU, the button and often the picture. sir.variantPicker does all of it with no React: you render the option buttons and the variant table on the server, and the shared runtime does the rest.

Create blocks/buy-box/buy-box.tsx:

blocks/buy-box/buy-box.tsx
import { block, f, Money, product, responsiveImage, sir } from '@bizzpocket/theme';

const SIZES = '(max-width: 768px) 100vw, 50vw';

export default block({
  name: 'Buy box',
  category: 'commerce',
  fields: {
    // On a product page the host fills it with the URL's handle.
    handle: f.text({ default: 'placeholder', hidden: true }),
    addLabel: f.text({ default: 'Add to cart' }),
    soldOutLabel: f.text({ default: 'Sold out' }),
    chooseLabel: f.text({ default: 'Choose options' }),
  },
  data: ({ handle }) => ({ item: product({ handle }) }),
  render: ({ item, addLabel, soldOutLabel, chooseLabel }) => {
    if (!item) return null;
    // The variant table: options as { name: value }, and every variant names a picture.
    const rows = item.variants.map((v) => {
      const img = v.image ?? item.primaryImage ?? null;
      return {
        id: v.id,
        options: Object.fromEntries(v.options.map((o) => [o.name, o.value])),
        price: v.price,
        compareAtPrice: v.compareAtPrice ?? null,
        sku: v.sku,
        inStock: v.inStock,
        image: img && { ...img, srcset: responsiveImage({ src: img.src, sizes: SIZES }).srcSet ?? '' },
      };
    });
    // The opening state (the first in-stock variant). The runtime starts from the same one.
    const { groups, selection, variant } = sir.variantState(rows);
    const price = variant?.price ?? item.price;
    const compare = variant ? variant.compareAtPrice : item.compareAtPrice;
    const picture = variant?.image ?? item.primaryImage;
    const label = Object.keys(selection).length === 0 ? chooseLabel : variant?.inStock ? addLabel : soldOutLabel;

    return (
      <section>
        {/* The main picture may sit anywhere on the page: it is linked by the product id. */}
        {picture ? (
          <img
            {...responsiveImage({ src: picture.src, sizes: SIZES })}
            {...sir.variantImage(item.id)}
            alt={picture.alt}
            width={800}
            height={800}
          />
        ) : null}
        <div {...sir.variantPicker({ 'data-product-id': item.id, 'data-locale': 'en-IN' })}>
          <h1>{item.title}</h1>
          <p>
            <span {...sir.variantTarget('price')}>
              <Money amount={price.amount} currency={price.currency} locale="en-IN" />
            </span>{' '}
            <s {...sir.variantTarget('compare')} hidden={!compare}>
              {compare ? <Money amount={compare.amount} currency={compare.currency} locale="en-IN" /> : null}
            </s>
          </p>
          {groups.map((g) => (
            <fieldset key={g.name}>
              <legend>{g.name}</legend>
              <div {...sir.variantGroup(g.name)}>
                {g.values.map((v) => (
                  <button key={v.value} type="button" {...sir.variantOption({ name: g.name, ...v })}>
                    {v.value}
                  </button>
                ))}
              </div>
            </fieldset>
          ))}
          <button
            type="button"
            disabled={variant?.inStock !== true}
            {...sir.addToCart({
              'data-product-id': item.id,
              'data-handle': item.handle,
              ...(variant ? { 'data-variant-id': variant.id } : {}),
              'data-label-add': addLabel,
              'data-label-oos': soldOutLabel,
              'data-label-select': chooseLabel,
            })}
          >
            {label}
          </button>
          <p {...sir.variantTarget('row')} hidden={!variant?.sku}>
            SKU <span {...sir.variantTarget('sku')}>{variant?.sku}</span>
          </p>
          <script {...sir.variantData(rows)} />
        </div>
      </section>
    );
  },
});

The block's client gz is 0. What each piece does:

Piece Role
sir.variantPicker({ 'data-product-id' }) The root. Everything below it that the pick updates must be inside it, except the main picture.
sir.variantState(rows) The opening state for your server HTML: selection, the chosen variant, and groups with each value's selected and unavailable.
sir.variantGroup(name), sir.variantOption({ name, value, selected, unavailable }) One radio group per option. The arrow keys (and Home / End) move the choice inside a group, and each group is one tab stop. A value no in-stock variant can reach gets aria-disabled="true" and data-unavailable. It stays focusable and can still be picked; the button then shows the sold-out label. Style it with [aria-disabled="true"], not :disabled.
sir.variantData(rows) The variant table, as JSON in a <script type="application/json">. A row is { id, options: { Size: 'M' }, price: { amount, currency }, compareAtPrice?, sku?, inStock, image?: { src, alt, srcset? } }.
sir.variantTarget('price') / ('compare') The price text, formatted with Intl.NumberFormat and the root's data-locale, the way <Money> formats it. Pass the same locale to both, so nothing changes when the runtime starts. The compare-at price is hidden when the variant has none.
sir.variantTarget('sku') / ('stock') The SKU, and a stock text taken from the target's data-label-in-stock / data-label-out-of-stock. An empty value hides the nearest sir.variantTarget('row') around it (or the target itself).
the sir.addToCart button inside the root Gets the chosen data-variant-id, is disabled unless the variant is in stock, and shows data-label-add, data-label-oos or data-label-select. Put the text in a child with sir.variantTarget('label') if the button also holds an icon.
sir.variantImage(productId) The main <img>: it gets the chosen variant's src, srcset and alt. A variant with no image brings back the picture the page opened with.

What a pick does, in order: it selects the value (when that value and the other choices form no variant, the other options move to the closest variant that has it), marks the unavailable values, updates the targets, the button and the picture, announces the price and stock in a polite live region (add your own element with sir.variantTarget('live'), or the runtime adds a visually hidden one), and writes ?variant=<id> with history.replaceState. A page opened with ?variant=<id> starts on that variant.

It also tells the rest of the page. The runtime dispatches bizzpocket:variant:change on window with { productId, variantId, variant } (variantId is null when the choice matches no variant), and sets the shared value variant:<productId> to the same object, opening state included. In an island, read it with useShared('variant:' + productId, null) from @bizzpocket/theme/client. Without the hook, the value is globalThis.__bpShared.get('variant:<productId>') (a Map) and each change fires bizzpocket:shared:variant:<productId>.

In bizzpocket dev the runtime fires the cart and wishlist events but there is no real cart behind them, so a click shows no cart change there. On a real storefront the host handles them.


Which one should I use?

  • Want a product to be added to the cart, a heart, an accordion, a carousel, a quantity box, or a size / colour choice on a product page? sir.*. No JavaScript from you.
  • Want the cart count, or to show something only to signed-in shoppers? An island with useCart() / useCustomer().
  • Want your own widget (a size guide that opens, a price calculator, a live filter)? An island with plain React state.
  • Want an island's button to say "Adding..." and show why an add failed? useCartActions().
  • Want two islands to follow one choice (a size and its price)? useShared().
  • Want data only after the shopper types or clicks (suggestions, "load more")? useQuery().
  • Want a form island? useForm().
  • Want a block to show different data when the shopper changes a filter or the page number? Link to the page with a query string (?sort=price). The page is rebuilt on the server with the new route values, and data() reads them from page.route. No JavaScript needed.

Two more small helpers from @bizzpocket/theme that every block uses:

  • <Image src alt width height sizes srcSet widths priority className />: a plain <img> with lazy loading. Set priority on the main picture of the first screen (the lcp block) to load it eagerly with high priority. An unsafe src (anything but https: or a path starting with a single /) renders nothing.

    • srcset comes by itself. When src is a BizzPocket CDN image (/v1/img/...) or an Unsplash image, <Image> adds a srcset and sizes (from responsiveImage()), so a phone downloads a phone-sized picture. src, width and height stay as you passed them, so the layout does not move. Any other host gets no srcset.
    • sizes is the slot's width. Pass it when you know it (sizes="(min-width: 768px) 25vw, 50vw"). Without it, <Image> uses (max-width: <width>px) 100vw, <width>px when width is set, else 100vw.
    • widths replaces the candidate widths (widths={[400, 800]}). On the CDN its largest value caps the CDN's own width ladder.
    • srcSet is passed through untouched when you give one. srcSet="" turns the automatic one off.
    tsx
    <Image src={p.image.src} alt={p.image.alt} width={400} height={400} sizes="(min-width: 768px) 25vw, 50vw" />
  • <Link href className>: an <a> that drops an unsafe href. Safe links are paths starting with a single /, #anchors, https:, mailto: and tel:.

  • <Money amount currency locale className />: a formatted price.

Next: registry.md.