Interactivity: islands, hooks and sir attributes
On this page
- Islands: a 'use client' file
- The rules
- What an island may import
- When does an island wake up? hydrate
- Seeing the cost
- Client hooks
- Cart actions: useCartActions()
- Shared state between islands: useShared()
- Data from the browser: useQuery()
- Forms: useForm()
- Worked example: size picker, add to cart and a price that follows
- sir attributes: behaviours without React
- Variant picker
- Which one should I use?
- Images and links
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:
'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:
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
- 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 examplecomponents/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 itsuseStateandonClicknever run in the browser. Using a hook in a file without it stops the build withCLIENT_HOOK_IN_SERVER. - The file name is the island's name. Two client files of one block whose names differ only by
extension (
heart.tsandheart.tsx), or a folder file and an outside file with the same name, clash. That isDUPLICATE_ISLAND. - 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. - 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, anew X()instance or a JSX element. The build stops you withNON_SERIALIZABLE_ISLAND_PROPand names the prop. The fix is to move the logic into the island and pass data instead. - No JSX children. Elements you nest inside an island tag are not sent to the browser. Pass what the island needs as props.
- 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.
- 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:
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:
'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:
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 }:
codeis the store's own reason, for exampleOUT_OF_STOCKorVARIANT_REQUIRED;TIMEOUTmeans the storefront did not answer within 15 seconds;messageis 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:
'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:
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.
updateandremovefail only withTIMEOUTtoday: 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)andremoveCartLine(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:
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)orset((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
setis treated as an updater, as inuseState.
How it works, for code that is not an island (a SIR behaviour, a script):
-
The values live in
globalThis.__bpShared, aMap<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
windowas the eventbizzpocket:shared:<key>, with the new value as itsdetail. -
So to write a value yourself, set it in the map and send the event:
tsconst 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 withuseShared('variant:' + productId, null). -
Outside React:
getShared(key),setShared(key, value)andsubscribeShared(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:
'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:
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:
- It stops the page from reloading.
- It runs the browser's own checks (
required,type="email",pattern,minLength...) and shows the browser's message when one fails. - It calls your optional
validate(data, form). Return a message to stop and show it; return nothing to go on. - It calls
submit(data, form)with the form'sFormData. Throw anError(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:
'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:
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:
'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:
'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:
'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:
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:
<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:
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:
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:
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 devthe 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, anddata()reads them frompage.route. No JavaScript needed.
Images and links
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. Setpriorityon the main picture of the first screen (thelcpblock) to load it eagerly with high priority. An unsafesrc(anything buthttps:or a path starting with a single/) renders nothing.srcsetcomes by itself. Whensrcis a BizzPocket CDN image (/v1/img/...) or an Unsplash image,<Image>adds asrcsetandsizes(fromresponsiveImage()), so a phone downloads a phone-sized picture.src,widthandheightstay as you passed them, so the layout does not move. Any other host gets nosrcset.sizesis 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>pxwhenwidthis set, else100vw.widthsreplaces the candidate widths (widths={[400, 800]}). On the CDN its largest value caps the CDN's own width ladder.srcSetis 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 unsafehref. Safe links are paths starting with a single/,#anchors,https:,mailto:andtel:. -
<Money amount currency locale className />: a formatted price.
Next: registry.md.